Compare commits
585 Commits
v0.0.55-un
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
826f21763d | ||
|
|
fd998e4834 | ||
|
|
564dceb303 | ||
|
|
30cc9f539f | ||
|
|
3f7dcf6e77 | ||
|
|
8be607a81b | ||
|
|
1ed31d22e9 | ||
|
|
1abdaa68f1 | ||
|
|
0f4dd1c464 | ||
|
|
196033844a | ||
|
|
7e9db8c1f6 | ||
|
|
f59cca4ea4 | ||
|
|
0ea92271cb | ||
|
|
08c81f97a2 | ||
|
|
869987bdb1 | ||
|
|
e0fda77c56 | ||
|
|
cdc13a0267 | ||
|
|
56a4f3fdd8 | ||
|
|
a72967e575 | ||
|
|
a66ade1a8d | ||
|
|
1468d112a9 | ||
|
|
5f405d39e6 | ||
|
|
bc7b70ef12 | ||
|
|
323caa2739 | ||
|
|
a01c8e4373 | ||
|
|
c608670b5a | ||
|
|
c360ddae05 | ||
|
|
393de7429c | ||
|
|
acc49fcd34 | ||
|
|
c427322f37 | ||
|
|
c2bb2c8df1 | ||
|
|
189a2adf4e | ||
|
|
a8cdc6f449 | ||
|
|
447c33027b | ||
|
|
de3a64c4c0 | ||
|
|
17094ea64d | ||
|
|
66804fe26c | ||
|
|
92cd5ecf8e | ||
|
|
c8a9c9ea53 | ||
|
|
4b80c6f0e5 | ||
|
|
ad1302aae4 | ||
|
|
2e3c0b63b2 | ||
|
|
18491bf70f | ||
|
|
f5a070eb80 | ||
|
|
a401703304 | ||
|
|
de26a312f7 | ||
|
|
60a619062a | ||
|
|
274f87dd7d | ||
|
|
400ceb4b9e | ||
|
|
27375a21ae | ||
|
|
d1b3aa91ea | ||
|
|
5fa414af5a | ||
|
|
507decff77 | ||
|
|
35b828ed63 | ||
|
|
32ddac1df0 | ||
|
|
88f03638fb | ||
|
|
6898b9f31d | ||
|
|
7665dcf6e8 | ||
|
|
005789a757 | ||
|
|
6af399a689 | ||
|
|
31f7011c86 | ||
|
|
f18b853575 | ||
|
|
3214febb38 | ||
|
|
cbf907788c | ||
|
|
7b174e95a7 | ||
|
|
0ad1b16369 | ||
|
|
1e72f73543 | ||
|
|
ee53210f2f | ||
|
|
1e2336d627 | ||
|
|
670b9a015d | ||
|
|
382a3796e1 | ||
|
|
989d37dcfc | ||
|
|
c2ffc65f83 | ||
|
|
0b1dc22e6c | ||
|
|
b7d61cb707 | ||
|
|
7b1e00d547 | ||
|
|
4c5739d659 | ||
|
|
b2024479f2 | ||
|
|
41627caad1 | ||
|
|
601d52a4c2 | ||
|
|
542d42cb76 | ||
|
|
dcceb19a95 | ||
|
|
395bc48c01 | ||
|
|
a56924463e | ||
|
|
78a5fa0429 | ||
|
|
460b552d37 | ||
|
|
84abfac78b | ||
|
|
2c11bb45fc | ||
|
|
388bf11e16 | ||
|
|
a8f8622072 | ||
|
|
26e883aef8 | ||
|
|
b0220b438b | ||
|
|
32dac911aa | ||
|
|
2fed574577 | ||
|
|
5fd21137e5 | ||
|
|
08f0edcc33 | ||
|
|
b6b5495dd5 | ||
|
|
247c6902e4 | ||
|
|
30252b7d07 | ||
|
|
9fb4db8d86 | ||
|
|
c409392797 | ||
|
|
e001fc2ce7 | ||
|
|
58aa84d19f | ||
|
|
78087c16cc | ||
|
|
a474f5a7ce | ||
|
|
f1db4742b5 | ||
|
|
0ff6495872 | ||
|
|
0b4a8c4f6a | ||
|
|
a16e6dce66 | ||
|
|
b9d6fe9ff1 | ||
|
|
2ca8b37971 | ||
|
|
5d072b76bb | ||
|
|
eb2c3a2199 | ||
|
|
168838dbae | ||
|
|
8c22340978 | ||
|
|
5913b7b3e9 | ||
|
|
1002b0dc76 | ||
|
|
110be23b1b | ||
|
|
2a843b8d0f | ||
|
|
7e1d48226c | ||
|
|
43fc475d01 | ||
|
|
9ceb87a351 | ||
|
|
a47bc2d600 | ||
|
|
1390a94642 | ||
|
|
588c940494 | ||
|
|
4835763e58 | ||
|
|
fb5791d0d6 | ||
|
|
06d877cd91 | ||
|
|
485144c791 | ||
|
|
9bda2960e8 | ||
|
|
ce22bcd12a | ||
|
|
ae7f0ce703 | ||
|
|
a2ed1c1ec8 | ||
|
|
184eb2a42c | ||
|
|
5db00c2ec9 | ||
|
|
1ea8b93a7b | ||
|
|
8675a84b57 | ||
|
|
44341f9c9f | ||
|
|
b7770ca800 | ||
|
|
01ecb7e5f9 | ||
|
|
b87e2069cc | ||
|
|
ac8bde9e75 | ||
|
|
d682394aca | ||
|
|
5e3ea57e89 | ||
|
|
a2f737649f | ||
|
|
d6a992279b | ||
|
|
98c9ed2f81 | ||
|
|
e4b3acf9bc | ||
|
|
1e47a999a5 | ||
|
|
f2888ab4d8 | ||
|
|
cedf8e036c | ||
|
|
8a93b7af05 | ||
|
|
6151216121 | ||
|
|
dbbf1b0ae2 | ||
|
|
b7209427bc | ||
|
|
337c29433d | ||
|
|
535b6cec5d | ||
|
|
cbe7a53667 | ||
|
|
56053f1d8e | ||
|
|
65507f8cb2 | ||
|
|
f134a75e98 | ||
|
|
df382f26f7 | ||
|
|
ad6dcb4a33 | ||
|
|
27b5184852 | ||
|
|
210a93043a | ||
|
|
ceb9a4574b | ||
|
|
35553f8285 | ||
|
|
4236867992 | ||
|
|
4ec285fecb | ||
|
|
6b122aae5f | ||
|
|
2c27a87b6b | ||
|
|
fb001f22f0 | ||
|
|
c75c9bc8e1 | ||
|
|
c2731f0a34 | ||
|
|
8d844f0ae3 | ||
|
|
91a5f6337e | ||
|
|
a742da3ae0 | ||
|
|
d0f17417b7 | ||
|
|
1854417b6c | ||
|
|
0cf9bb314a | ||
|
|
715e03d154 | ||
|
|
445e9a5072 | ||
|
|
f12940bcca | ||
|
|
ac5018665c | ||
|
|
8cce4a5d4b | ||
|
|
543209a087 | ||
|
|
3cdc027c83 | ||
|
|
21371febd2 | ||
|
|
62339d2de3 | ||
|
|
fe2e7770fa | ||
|
|
8946809ba3 | ||
|
|
748677ac50 | ||
|
|
7a8031adc5 | ||
|
|
fbbcf95bdd | ||
|
|
542fa93b5b | ||
|
|
eca8d44af0 | ||
|
|
06e6232ce8 | ||
|
|
a6b3bfc9f3 | ||
|
|
2341c1c7d7 | ||
|
|
ed4872ce62 | ||
|
|
4f80719233 | ||
|
|
de3f859dea | ||
|
|
8889d5a456 | ||
|
|
99a150c9cf | ||
|
|
8935944c88 | ||
|
|
3b422b9bea | ||
|
|
3a3567bc77 | ||
|
|
4ee9f5c3e7 | ||
|
|
b4941a4e44 | ||
|
|
a2e698bf09 | ||
|
|
7e1c5095f5 | ||
|
|
80b1fb8ce7 | ||
|
|
276b40006d | ||
|
|
b9365115aa | ||
|
|
415add6a06 | ||
|
|
bb0a480af1 | ||
|
|
129e59deb9 | ||
|
|
275ae23e1e | ||
|
|
cf4a60cd91 | ||
|
|
c836528e50 | ||
|
|
921b48aa3b | ||
|
|
42b9bf305e | ||
|
|
87b6bb6d85 | ||
|
|
d6b2bd1d5e | ||
|
|
3fadcc487c | ||
|
|
33f8d59959 | ||
|
|
f239a18a0b | ||
|
|
d39be13bf2 | ||
|
|
47b2fe8dd4 | ||
|
|
415ed27196 | ||
|
|
7756bcc0da | ||
|
|
1998d5c1e1 | ||
|
|
1978a9e837 | ||
|
|
42330a1215 | ||
|
|
b118a123c1 | ||
|
|
0fc689bc3e | ||
|
|
c97ff8f24e | ||
|
|
56d6ae3bde | ||
|
|
fa42a3a687 | ||
|
|
480f12b4e9 | ||
|
|
766241eaec | ||
|
|
da8ac07567 | ||
|
|
9982f3c3db | ||
|
|
5650d07a54 | ||
|
|
9f194e62c6 | ||
|
|
f32a6d1397 | ||
|
|
755cf3ada8 | ||
|
|
6c88a9f414 | ||
|
|
027e9faaa8 | ||
|
|
b7b489791a | ||
|
|
54a6f14ff6 | ||
|
|
15f3da9434 | ||
|
|
f69b23e10e | ||
|
|
3bf63be768 | ||
|
|
e64dc93dca | ||
|
|
d40831a019 | ||
|
|
1b264b48b2 | ||
|
|
1d502f212a | ||
|
|
a29c1ce8dc | ||
|
|
c7703a2b77 | ||
|
|
4c3e8f8d83 | ||
|
|
c495a5ae36 | ||
|
|
62cf02addf | ||
|
|
f6537bad61 | ||
|
|
6da809a57f | ||
|
|
f0ad76b76c | ||
|
|
edbfce11e7 | ||
|
|
6696d626fc | ||
|
|
c8109aaa71 | ||
|
|
52af53eed9 | ||
|
|
41baddedba | ||
|
|
4d3998b5a6 | ||
|
|
9755d11689 | ||
|
|
dfac30b4ce | ||
|
|
73cc9fb772 | ||
|
|
5f4ef4386d | ||
|
|
3661aa9aba | ||
|
|
d0ac96acf4 | ||
|
|
ee50957974 | ||
|
|
526b18275b | ||
|
|
9169ec65e3 | ||
|
|
90e45ee707 | ||
|
|
17e565ee6f | ||
|
|
59253cd9ca | ||
|
|
2ac71e5864 | ||
|
|
c30f5713ba | ||
|
|
1a26be30d6 | ||
|
|
5fc2f7a00f | ||
|
|
de4a80d39f | ||
|
|
86d1329b8c | ||
|
|
a1ca124c5a | ||
|
|
a3b74e8af5 | ||
|
|
7bea25db75 | ||
|
|
1989f5ca83 | ||
|
|
3762f032d3 | ||
|
|
f428dbecf0 | ||
|
|
d52f0adf87 | ||
|
|
1694a45721 | ||
|
|
e939ddb306 | ||
|
|
c176afa8a4 | ||
|
|
f5d073ddc1 | ||
|
|
c06f1b0aad | ||
|
|
fc79a1d79f | ||
|
|
6329641d4c | ||
|
|
4e842c71ee | ||
|
|
110bd19a61 | ||
|
|
1254928cd6 | ||
|
|
e7030764b0 | ||
|
|
852d09fc55 | ||
|
|
63e1a74151 | ||
|
|
ad08fca671 | ||
|
|
20ed585c99 | ||
|
|
fdeff28979 | ||
|
|
7a1cc1632c | ||
|
|
f878125320 | ||
|
|
77145d09d0 | ||
|
|
a8e4e1ae90 | ||
|
|
b1dc992e6d | ||
|
|
8a9043067b | ||
|
|
ad87a746bd | ||
|
|
d95187a809 | ||
|
|
4a19dc69fc | ||
|
|
573527ebee | ||
|
|
61e9f0cc67 | ||
|
|
1f498d611c | ||
|
|
5488e4a2bf | ||
|
|
88c9c8e17d | ||
|
|
1d3b5c9408 | ||
|
|
dbfc8bcbf7 | ||
|
|
ed4224f3d7 | ||
|
|
3ab4b3328c | ||
|
|
ca99b0c4ef | ||
|
|
f8b842bf0e | ||
|
|
aac156ba85 | ||
|
|
759ac4f2ff | ||
|
|
27c06f5ea0 | ||
|
|
82a6ad8acf | ||
|
|
3ae972841a | ||
|
|
c548ede724 | ||
|
|
09e19c5d20 | ||
|
|
218dcc9524 | ||
|
|
cd28ee24a5 | ||
|
|
23ef2ca7c6 | ||
|
|
b561806f13 | ||
|
|
59aa843fb6 | ||
|
|
f2bb9f69cd | ||
|
|
e864de124b | ||
|
|
cdcc2abb6c | ||
|
|
734636cb95 | ||
|
|
35cef7ec10 | ||
|
|
c967542922 | ||
|
|
bfffe11dbd | ||
|
|
397f7c6ace | ||
|
|
cdc9ebceff | ||
|
|
6a327a937e | ||
|
|
863d8c0bfc | ||
|
|
40a34d2bba | ||
|
|
1d853d73f5 | ||
|
|
8c35d09895 | ||
|
|
70078c0140 | ||
|
|
a827316723 | ||
|
|
8ccbef998a | ||
|
|
7aef9f21c7 | ||
|
|
5962022ef3 | ||
|
|
3b5eb0475c | ||
|
|
31b1c4b9b1 | ||
|
|
1067d03442 | ||
|
|
de73640753 | ||
|
|
35f0d814db | ||
|
|
de04fa2c15 | ||
|
|
fa3cea0d52 | ||
|
|
0c5e54195f | ||
|
|
624de57ae9 | ||
|
|
189deacba0 | ||
|
|
8077d7de53 | ||
|
|
0435787761 | ||
|
|
6cc0cce2d2 | ||
|
|
0378577fc6 | ||
|
|
398ee0e83f | ||
|
|
f719fc2a91 | ||
|
|
23449b5021 | ||
|
|
bd0ca01281 | ||
|
|
9a01c4dc4d | ||
|
|
5a1db96837 | ||
|
|
7250248345 | ||
|
|
f18f1600c3 | ||
|
|
715088ef0a | ||
|
|
7a4c3bd709 | ||
|
|
c1269e48e6 | ||
|
|
de89618c88 | ||
|
|
e9a269e1f2 | ||
|
|
435e151258 | ||
|
|
c5a3a0de89 | ||
|
|
10e231adb1 | ||
|
|
c2a8bdc4c9 | ||
|
|
c114cab269 | ||
|
|
04107ab652 | ||
|
|
18bd87dcaf | ||
|
|
8ab7a29e02 | ||
|
|
2557b78c6b | ||
|
|
c6454aa227 | ||
|
|
c498223642 | ||
|
|
a0693c934a | ||
|
|
df7200b20e | ||
|
|
cd3012a042 | ||
|
|
a9e0eb2014 | ||
|
|
0df7622a32 | ||
|
|
837780fde9 | ||
|
|
665e71e24e | ||
|
|
c6fab8def4 | ||
|
|
8a6fb782ad | ||
|
|
43bf85db20 | ||
|
|
c4514b3255 | ||
|
|
e9fe6001e1 | ||
|
|
9a26b1f0ea | ||
|
|
a9341f12c8 | ||
|
|
01f8557ba6 | ||
|
|
3e1d207e1b | ||
|
|
885204a1bd | ||
|
|
a2bdb5c1ea | ||
|
|
51c978ce37 | ||
|
|
760f13cecc | ||
|
|
0bc6c4a7a2 | ||
|
|
d1a9be6058 | ||
|
|
4c385d0442 | ||
|
|
9dd82c7d30 | ||
|
|
621f261a59 | ||
|
|
098df2eba2 | ||
|
|
10c615a828 | ||
|
|
44d90df24b | ||
|
|
2a72744809 | ||
|
|
4bfef9fd38 | ||
|
|
e53d7f7dcd | ||
|
|
a81cf78f5c | ||
|
|
7f342b6ebc | ||
|
|
87de403f00 | ||
|
|
eb52242609 | ||
|
|
baf236102e | ||
|
|
adbc6ede51 | ||
|
|
4667ccc050 | ||
|
|
d4cb8c0429 | ||
|
|
36939ccf74 | ||
|
|
fa58d59c82 | ||
|
|
2bcb14083e | ||
|
|
a08bc351d5 | ||
|
|
8fc9430aaa | ||
|
|
2b6d148b47 | ||
|
|
910b56a5c0 | ||
|
|
29ea556b23 | ||
|
|
2e0ffb27fc | ||
|
|
1c03df78d3 | ||
|
|
1a874f62d8 | ||
|
|
904444ebc5 | ||
|
|
61b6d67f21 | ||
|
|
93c9ad710b | ||
|
|
5a0080c6b7 | ||
|
|
e506ac15b0 | ||
|
|
87fb2baa73 | ||
|
|
0158832fe0 | ||
|
|
9e89b572bb | ||
|
|
307507a223 | ||
|
|
7dcaf70608 | ||
|
|
9b636ae2d4 | ||
|
|
1395c681ca | ||
|
|
07b96e258d | ||
|
|
4dffb666d3 | ||
|
|
0dc116ada2 | ||
|
|
7bad6ffcae | ||
|
|
15d889c8bb | ||
|
|
e2b94c62cb | ||
|
|
d5661201ed | ||
|
|
d4607a86a6 | ||
|
|
27823ac665 | ||
|
|
5bf8bab6f2 | ||
|
|
569d24d3a1 | ||
|
|
47d9b235b6 | ||
|
|
62a58a5a46 | ||
|
|
89132d8ac8 | ||
|
|
461dfea071 | ||
|
|
ee08b3a601 | ||
|
|
98c5f5c118 | ||
|
|
40079d385d | ||
|
|
9bd3e9063b | ||
|
|
a24ce52f41 | ||
|
|
80dfcc17a0 | ||
|
|
d6a15c39aa | ||
|
|
0499d434a8 | ||
|
|
665487e812 | ||
|
|
5411f6c17b | ||
|
|
05833c3d14 | ||
|
|
c342d42db9 | ||
|
|
c6787f9f2a | ||
|
|
1c0b3c1620 | ||
|
|
2138176689 | ||
|
|
c4a0b7af96 | ||
|
|
2859c90b2c | ||
|
|
00e0e8b5ba | ||
|
|
78e7b8d1a9 | ||
|
|
3e1e637fd4 | ||
|
|
6a13a513a6 | ||
|
|
8422bae2ed | ||
|
|
b1f3090dcb | ||
|
|
973a2f9721 | ||
|
|
5fcb5d9571 | ||
|
|
971697405b | ||
|
|
3ab7de3769 | ||
|
|
4b800090e2 | ||
|
|
1f5bd3e5a0 | ||
|
|
9cc5bd4a88 | ||
|
|
3022dfe375 | ||
|
|
55c22846bf | ||
|
|
e87e1c13a1 | ||
|
|
f80d763e3c | ||
|
|
4f0451c285 | ||
|
|
5138ced433 | ||
|
|
7355b52dcd | ||
|
|
389b54cc96 | ||
|
|
19e9eb7f58 | ||
|
|
2efe1c0f48 | ||
|
|
ba4c6cd9d0 | ||
|
|
42f6ba54ea | ||
|
|
ab0da60228 | ||
|
|
e057b5eb9d | ||
|
|
b43eb4703b | ||
|
|
968e567b92 | ||
|
|
cc299bb0bc | ||
|
|
77b8da63c4 | ||
|
|
52f9b20764 | ||
|
|
01494250e9 | ||
|
|
709b8e1605 | ||
|
|
f4c2ac4940 | ||
|
|
05a8b2c4af | ||
|
|
f11cab29d6 | ||
|
|
5efc5c6afc | ||
|
|
c5cadb74bd | ||
|
|
f054d8dfcb | ||
|
|
ac60c32d2c | ||
|
|
f30a14b204 | ||
|
|
54349f757b | ||
|
|
b1163e2d00 | ||
|
|
65fa8a22dd | ||
|
|
b911f29b5f | ||
|
|
b54b5d9112 | ||
|
|
db70e56129 | ||
|
|
f715e8f160 | ||
|
|
053c6d53fc | ||
|
|
23f42e42eb | ||
|
|
3431f06655 | ||
|
|
e72d0bf160 | ||
|
|
a06989bf26 | ||
|
|
027f9a1a74 | ||
|
|
5a377655a6 | ||
|
|
005a514660 | ||
|
|
d8892a4dc6 | ||
|
|
f77a619540 | ||
|
|
0bc810ccec | ||
|
|
4f9bbba5e5 | ||
|
|
7d9fc9f3cf | ||
|
|
71fc2e278a | ||
|
|
000928024c | ||
|
|
b0f310adb0 | ||
|
|
21afdec8ba | ||
|
|
20934d05d3 | ||
|
|
9fb1498225 | ||
|
|
82bfd50535 | ||
|
|
06c11a1d16 | ||
|
|
3184306e86 | ||
|
|
451b4ede14 | ||
|
|
11bf5021d4 | ||
|
|
97b8fd73e1 | ||
|
|
370e68a189 | ||
|
|
e1d79490a5 | ||
|
|
0e1d2e4bb1 | ||
|
|
3fef59c4da | ||
|
|
3391e9173f | ||
|
|
3c3b3544f1 | ||
|
|
9414f0b971 | ||
|
|
0450dcec37 | ||
|
|
861c1d9bda | ||
|
|
664d3b30c9 | ||
|
|
8e9d9e9a11 | ||
|
|
becb8bc176 | ||
|
|
e068b4bb3c | ||
|
|
47b0bf54b8 | ||
|
|
003602f92f |
@ -1,9 +1,10 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
*Dockerfile*
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
bin
|
||||
dist
|
||||
.pseudotv
|
||||
.pseudotv
|
||||
.dizquetv
|
||||
13
.github/workflows/README.md
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# The workflow
|
||||
|
||||
edge main
|
||||
^ \ ^
|
||||
| \------2--\ |
|
||||
1 \ 3
|
||||
| \ |
|
||||
development <-4-- patch
|
||||
|
||||
1. Releasing a new 'edge' version.
|
||||
2. Moving an 'edge' version to stable.
|
||||
3. Releasing a stable version
|
||||
4. Aligning bug fixes with the new features.
|
||||
46
.github/workflows/binaries-build.yaml
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
name: Build Executables and Update Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
release-files:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Build dist image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile-builder
|
||||
load: true
|
||||
tags: builder
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Run dist docker
|
||||
run: |
|
||||
docker run -v ./dist:/home/node/app/dist builder sh make_dist.sh
|
||||
|
||||
|
||||
- name: Upload Files
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ inputs.release }}
|
||||
files: |
|
||||
./dist/dizquetv-win-x64.exe
|
||||
./dist/dizquetv-win-x86.exe
|
||||
./dist/dizquetv-linux-x64
|
||||
./dist/dizquetv-macos-x64
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
13
.github/workflows/development-binaries.yaml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
name: Development Binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
|
||||
jobs:
|
||||
binaries:
|
||||
uses: ./.github/workflows/binaries-build.yaml
|
||||
with:
|
||||
release: development-binaries
|
||||
secrets: inherit
|
||||
13
.github/workflows/development-tag.yaml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
name: Development Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
uses: ./.github/workflows/docker-build.yaml
|
||||
with:
|
||||
tag: development
|
||||
secrets: inherit
|
||||
44
.github/workflows/docker-build.yaml
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
name: Docker Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: Default
|
||||
Dockerfile: Dockerfile
|
||||
suffix: ""
|
||||
- name: nvidia
|
||||
Dockerfile: Dockerfile-nvidia
|
||||
suffix: "-nvidia"
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
file: ${{ matrix.Dockerfile }}
|
||||
tags: vexorian/dizquetv:${{ inputs.tag }}${{ matrix.suffix }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
13
.github/workflows/edge-tag.yaml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
name: Edge Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- edge
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
uses: ./.github/workflows/docker-build.yaml
|
||||
with:
|
||||
tag: edge
|
||||
secrets: inherit
|
||||
13
.github/workflows/latest-tag.yaml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
name: Latest Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
uses: ./.github/workflows/docker-build.yaml
|
||||
with:
|
||||
tag: latest
|
||||
secrets: inherit
|
||||
13
.github/workflows/named-tag.yaml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
name: Named Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
uses: ./.github/workflows/docker-build.yaml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
secrets: inherit
|
||||
13
.github/workflows/tag-binaries.yaml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
name: Release Binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
binaries:
|
||||
uses: ./.github/workflows/binaries-build.yaml
|
||||
with:
|
||||
release: ${{ github.ref_name }}
|
||||
secrets: inherit
|
||||
5
.gitignore
vendored
@ -2,4 +2,7 @@ node_modules/
|
||||
dist/
|
||||
bin/
|
||||
.pseudotv/
|
||||
web/public/bundle.js
|
||||
.dizquetv/
|
||||
web/public/bundle.js
|
||||
*.orig
|
||||
package-lock.json
|
||||
67
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,67 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We pledge to make our community welcoming, safe, and equitable for all.
|
||||
|
||||
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
|
||||
|
||||
|
||||
## Encouraged Behaviors
|
||||
|
||||
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language.
|
||||
|
||||
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including:
|
||||
|
||||
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
|
||||
2. Engaging **kindly and honestly** with others.
|
||||
3. Respecting **different viewpoints** and experiences.
|
||||
4. **Taking responsibility** for our actions and contributions.
|
||||
5. Gracefully giving and accepting **constructive feedback**.
|
||||
6. Committing to **repairing harm** when it occurs.
|
||||
7. Behaving in other ways that promote and sustain the **well-being of our community**.
|
||||
|
||||
|
||||
## Restricted Behaviors
|
||||
|
||||
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct.
|
||||
|
||||
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop.
|
||||
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people.
|
||||
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable identities or traits.
|
||||
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community.
|
||||
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission.
|
||||
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
|
||||
7. Behaving in other ways that **threaten the well-being** of our community.
|
||||
|
||||
### Other Restrictions
|
||||
|
||||
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions.
|
||||
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
|
||||
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community.
|
||||
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors.
|
||||
|
||||
## 'AI' policy
|
||||
|
||||
There are ways in which a LLM-based tool can be helpful such as when searching the web or for learning. More so, tools like github and google themselves are being modified to railroad users into using LLM-based tools. It'd be really impractical and neigh-impossible to ban 'AI' altogether from being used during the development of a contribution and that's not really the purpose of this policy.
|
||||
|
||||
HOWEVER, from a legal standpoint, it is too difficult to know the origin of LLM-generated code so there are risks of it infringing on copyright. And from a pragmatic stand point, we want to be able to trust the quality of the code. That is to say, we do not want contributions where the bulk of the code was AI-generated or or where the contributors themselves do not understand the code being pushed. This is also in relation to items 4 and 5 of the encouraged behaviors. We want contributors that can vouch for the code they contribute and can receive and act on constructive feedback to it.
|
||||
|
||||
Note that this is only a policy affecting Pull Requests to this project. This project is released under a permissive FOSS licence, so there's nothing really that can stop forks from having a different policy on this and any individual is allowed to create such a fork should they want to.
|
||||
|
||||
## Reporting an Issue
|
||||
|
||||
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm.
|
||||
|
||||
When an incident does occur, it is important to report it promptly. To report a possible violation, may be
|
||||
reported by contacting the project team at vexorian@gmail.com
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 3.0,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html . The AI policy is a modification.
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
58
Dockerfile
@ -1,50 +1,14 @@
|
||||
FROM node:12.18-alpine3.12
|
||||
# Should be ffmpeg v4.2.3
|
||||
|
||||
ARG LIBDAV1D_VERSION=0.7.1
|
||||
ARG LIBDAV1D_URL="https://code.videolan.org/videolan/dav1d/-/archive/$LIBDAV1D_VERSION/dav1d-$LIBDAV1D_VERSION.tar.gz"
|
||||
|
||||
RUN apk add --update \
|
||||
curl nasm yasm build-base gcc zlib-dev libc-dev openssl-dev yasm-dev lame-dev libogg-dev x264-dev libvpx-dev libvorbis-dev x265-dev freetype-dev libass-dev libwebp-dev rtmpdump-dev libtheora-dev opus-dev meson ninja && \
|
||||
wget -O dav1d.tar.gz "$LIBDAV1D_URL" && \
|
||||
tar xfz dav1d.tar.gz && \
|
||||
cd dav1d-* && meson build --buildtype release -Ddefault_library=static && ninja -C build install && \
|
||||
DIR=$(mktemp -d) && cd ${DIR} && \
|
||||
curl -s http://ffmpeg.org/releases/ffmpeg-4.2.3.tar.gz | tar zxvf - -C . && \
|
||||
cd ffmpeg-4.2.3 && \
|
||||
./configure \
|
||||
--enable-version3 \
|
||||
--enable-gpl \
|
||||
--enable-nonfree \
|
||||
--enable-small \
|
||||
--enable-libmp3lame \
|
||||
--enable-libx264 \
|
||||
--enable-libdav1d \
|
||||
--enable-libx265 \
|
||||
--enable-libvpx \
|
||||
--enable-libtheora \
|
||||
--enable-libvorbis \
|
||||
--enable-libopus \
|
||||
--enable-libass \
|
||||
--enable-libwebp \
|
||||
--enable-librtmp \
|
||||
--enable-postproc \
|
||||
--enable-avresample \
|
||||
--enable-libfreetype \
|
||||
--enable-openssl \
|
||||
--enable-filter=drawtext \
|
||||
--disable-debug && \
|
||||
make && \
|
||||
make install && \
|
||||
make distclean && \
|
||||
rm -rf ${DIR} && \
|
||||
mv /usr/local/bin/ffmpeg /usr/bin/ffmpeg && \
|
||||
apk del build-base curl tar bzip2 x264 openssl nasm openssl xz gnupg && rm -rf /v
|
||||
WORKDIR /home/node/app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm install -g browserify
|
||||
EXPOSE 8000
|
||||
CMD [ "npm", "start"]
|
||||
COPY package.json ./
|
||||
RUN npm install && npm install -g browserify nexe@3.3.7
|
||||
COPY --from=vexorian/dizquetv:nexecache /var/nexe/linux-x64-12.16.2 /var/nexe/
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN npm run build && LINUXBUILD=dizquetv sh make_dist.sh linuxonly
|
||||
|
||||
FROM jrottenberg/ffmpeg:4.3-ubuntu1804
|
||||
EXPOSE 8000
|
||||
WORKDIR /home/node/app
|
||||
ENTRYPOINT [ "./dizquetv" ]
|
||||
COPY --from=0 /home/node/app/dist/dizquetv /home/node/app/
|
||||
RUN ln -s /usr/local/bin/ffmpeg /usr/bin/ffmpeg
|
||||
|
||||
6
Dockerfile-builder
Normal file
@ -0,0 +1,6 @@
|
||||
FROM node:14-alpine3.14
|
||||
WORKDIR /home/node/app
|
||||
COPY package*.json ./
|
||||
RUN npm install && npm install -g browserify nexe@3.3.7
|
||||
COPY --from=vexorian/dizquetv:nexecache /var/nexe/* /var/nexe/
|
||||
COPY . .
|
||||
14
Dockerfile-nvidia
Normal file
@ -0,0 +1,14 @@
|
||||
FROM node:14-alpine3.14
|
||||
WORKDIR /home/node/app
|
||||
COPY package*.json ./
|
||||
RUN npm install && npm install -g browserify nexe@3.3.7
|
||||
COPY --from=vexorian/dizquetv:nexecache /var/nexe/linux-x64-12.16.2 /var/nexe/
|
||||
COPY . .
|
||||
RUN npm run build && LINUXBUILD=dizquetv sh make_dist.sh linuxonly
|
||||
|
||||
FROM jrottenberg/ffmpeg:4.3-nvidia1804
|
||||
EXPOSE 8000
|
||||
WORKDIR /home/node/app
|
||||
ENTRYPOINT [ "./dizquetv" ]
|
||||
COPY --from=0 /home/node/app/dist/dizquetv /home/node/app/
|
||||
RUN ln -s /usr/local/bin/ffmpeg /usr/bin/ffmpeg
|
||||
19
LICENSE
Normal file
@ -0,0 +1,19 @@
|
||||
zlib License
|
||||
|
||||
Copyright (c) 2020 Dan Ferguson, Victor Hugo Soliz Kuncar
|
||||
|
||||
This software is provided 'as-is', without any express or implied
|
||||
warranty. In no event will the authors be held liable for any damages
|
||||
arising from the use of this software.
|
||||
|
||||
Permission is granted to anyone to use this software for any purpose,
|
||||
including commercial applications, and to alter it and redistribute it
|
||||
freely, subject to the following restrictions:
|
||||
|
||||
1. The origin of this software must not be misrepresented; you must not
|
||||
claim that you wrote the original software. If you use this software
|
||||
in a product, an acknowledgment in the product documentation would be
|
||||
appreciated but is not required.
|
||||
2. Altered source versions must be plainly marked as such, and must not be
|
||||
misrepresented as being the original software.
|
||||
3. This notice may not be removed or altered from any source distribution.
|
||||
164
README.md
@ -1,142 +1,57 @@
|
||||
# pseudotv-plex
|
||||
# dizqueTV 1.5.5
|
||||
  
|
||||
|
||||
Create live TV channel streams from media on your Plex servers.
|
||||
|
||||
Project recently migrated from [gitlab](https://gitlab.com/DEFENDORe/pseudotv-plex) to github to improve development flow (docker builds and binary releases).
|
||||
**dizqueTV** ( *dis·keˈtiːˈvi* ) is a fork of the project previously-known as [pseudotv-plex](https://gitlab.com/DEFENDORe/pseudotv-plex) or [pseudotv](https://github.com/DEFENDORe/pseudotv). New repository because of lack of activity from the main repository and the name change is because projects with the old name already existed and were created long before this approach and it was causing confusion. You can migrate from pseudoTV 0.0.51 to dizqueTV by renaming the .pseudotv folder to .dizquetv and running the new executable (or doing a similar trick with the volumes used by the docker containers).
|
||||
|
||||
<img src="./resources/pseudotv.png" width="200">
|
||||
<img src="https://raw.githubusercontent.com/vexorian/dizquetv/main/resources/dizquetv.png" width="200">
|
||||
|
||||
Configure your channels, programs, commercials and settings using the PseudoTV web UI.
|
||||
Configure your channels, programs, commercials and settings using the dizqueTV web UI.
|
||||
|
||||
Access your channels by adding the spoofed PseudoTV HDHomerun tuner to Plex, or utilize the M3U Url with any 3rd party app.
|
||||
Access your channels by adding the spoofed dizqueTV HDHomerun tuner to Plex, Jellyfin or emby or utilize the M3U Url with any 3rd party IPTV player app.
|
||||
|
||||
EPG (Guide Information) data is stored to `.pseudotv/xmltv.xml`
|
||||
EPG (Guide Information) data is stored to `.dizquetv/xmltv.xml`
|
||||
|
||||
## Features
|
||||
- Docker image and prepackage binaries for Windows, Linux and Mac
|
||||
- Web UI for channel configuration and app settings
|
||||
- A wide variety of options for the clients where you can play the TV channels, since it both spoofs a HDHR tuner and a IPTV channel list.
|
||||
- Ease of setup for xteve and Plex playback by mocking a HDHR server.
|
||||
- Configure your channels once, and play them just the same in any of the other devices.
|
||||
- Customize your channels and what they play. Make them display their logo while they play. Play filler content ("commercials", music videos, prerolls, channel branding videos) at specific times to pad time.
|
||||
- Docker image and prepackage binaries for Windows, Linux and Mac.
|
||||
- Supports nvidia for hardware encoding, including in docker.
|
||||
- Select media (desired programs and commercials) across multiple Plex servers
|
||||
- Sign into your Plex servers using any sign in method (Username, Email, Google, Facebook, etc.)
|
||||
- Ability to auto update Plex DVR guide data and channel mappings
|
||||
- Auto update the xmltv.xml file at a set interval (in hours). You can also set the amount EPG cache (in hours).
|
||||
- Continuous playback support
|
||||
- Ability to add breaks or padding between episodes and use Commercials, Trailers, Bumpers or other filler.
|
||||
- Commercial support. 5 commercial slots for a program (BEFORE, 1/4, 1/2, 3/4, AFTER). Place as many commercials as desired per slot to chain commercials.
|
||||
- Media track selection (video, audio, subtitle). (subtitles disabled by default)
|
||||
- Includes a WEB TV Guide where you can even play channels in your desktop by using your local media player.
|
||||
- Subtitle support.
|
||||
- Ability to overlay channel icon over stream
|
||||
- Auto deinterlace any Plex media not marked `"scanType": "progressive"`
|
||||
- Can be configured to completely force Direct play.
|
||||
- Can normalize video formats to prevent stream breaking.
|
||||
|
||||
## Useful Tips/Info
|
||||
|
||||
- Internal and External SRT/ASS subtitles may cause a delay when starting stream (only when subtitles are activated). For internal SRT/ASS subtitles, FFMPEG needs to perform a subtitle track extraction from the original media before the requested stream can be started. External SRT/ASS subtitle files still need to be sliced to the correct start time and duration so even they may cause a delay when starting a stream. Image based subs (PGS) should have little to no impact.
|
||||
- Utilize your hardware accelerated encoders, or use mpeg2 instead of h264 by changing the default video encoder in FFMPEG settings. *Note that some encoders may not be capable of handling every transcoding scenario, libx264 and mpeg2video seem to be the most stable.*
|
||||
- Intel Quick Sync: `h264_qsv`, `mpeg2_qsv`
|
||||
- NVIDIA GPU: `h264_nvenc`
|
||||
- MPEG2 `mpeg2video`
|
||||
- H264 `libx264` (default)
|
||||
- MacOS `h264_videotoolbox`
|
||||
- **Enable the option to log ffmpeg's stderr output directly to the pseudotv app console, for detecting issues**
|
||||
- Host your own images for channel icons, program icons, etc.. Simply add your image to `.pseudotv/images` and reference them via `http://pseudotv-ip:8000/images/myImage.png`
|
||||
- Use the Block Shuffle feature to play a specified number of TV episodes before advancing to the next available TV show in the channel. You can also specify to randomize the TV Show order. Any movies added to the channel will be pushed to the end of the program lineup, this is also applicable the "Sort TV Shows" option.
|
||||
- Plex is smart enough not to open another stream if it currently is being viewed by another user. This allows only one transcode session for mulitple viewers if they are watching the same channel.
|
||||
- Even if your Plex server is running on the same machine as the PseudoTV app, use your network address (not a loopback) when configuring your Plex Server(s) in the web UI.
|
||||
- Can be configured to completely force Direct play, if you are ready for the caveats.
|
||||
- It's up to you if the channels have a life of their own and act as if they continued playing when you weren't watching them or if you want "on-demand" channels that stop their schedules while not being watched.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Plex Pass is required to unlock Plex Live TV/DVR feature
|
||||
- Only one EPG source can be used with Plex server. This may cause an issue if you are adding the pseudotv tuner to a Plex server with Live TV/DVR already enabled/configured.
|
||||
- If you want to play the TV channels in Plex using the spoofed HDHR, Plex pass is required.
|
||||
- dizqueTV does not currently watch your Plex server for media updates/changes. You must manually remove and re-add your programs for any changes to take effect. Same goes for Plex server changes (changing IP, port, etc).. You'll have to update the server settings manually in that case.
|
||||
- Most players (including Plex) will break after switching episodes if video / audio format is too different. dizqueTV can be configured to use ffmpeg transcoding to prevent this, but that costs resources.
|
||||
- If you configure Plex DVR, it will always be recording and transcoding the channel's contents.
|
||||
- In its current state, dizquetv is intended for private use only and you should be discouraged from running dizqueTV in any capacity where other users can have access to dizqueTV's ports. You can use Plex's iptv player feature to share dizqueTV streams or you'll have to come up with some work arounds to make sure that streams can be played without ever actually exposing the dizquetv port to the outside world. Please use it with care, consider exposing dizqueTV's ports as something only advanced users who know what they are doing should try.
|
||||
|
||||
* There are projects like xteve that allow you to unify multiple EPG sources into a single list which Plex can use.
|
||||
## Releases
|
||||
|
||||
- PseudoTV does not watch your Plex server for media updates/changes. You must manually remove and readd your programs for any changes to take effect. Same goes for Plex server changes (changing IP, port, etc).. all media will fail..
|
||||
- Many IPTV players (including Plex) will break after switching episodes if video / audio format is too different between. PseudoTV can be configured to use ffmpeg transcoding to prevent htis, but that costs resources.
|
||||
- https://github.com/vexorian/dizquetv/releases
|
||||
|
||||
## Installation
|
||||
## Wiki
|
||||
|
||||
*Please delete your old `.pseudotv` directory before using the new build. I'm sorry but it'd take more effort than its worth to convert the old databases..*
|
||||
- For setup instructions, check [the wiki](https://github.com/vexorian/dizquetv/wiki)
|
||||
|
||||
Unless your are using Docker/Unraid, you must download and install **ffmpeg** to your system and set the correct path in the PseudoTV Web UI.
|
||||
|
||||
By default, pseudotv will create the directory `.pseudotv` wherever pseudotv is launched from. Your `xmltv.xml` file and config databases are stored here.
|
||||
|
||||
#### Binary Release
|
||||
[Download](https://github.com/DEFENDORe/pseudotv/releases) and run the PseudoTV executable (argument defaults below)
|
||||
```
|
||||
./pseudotv-win-x64.exe --port 8000 --database ./pseudotv
|
||||
```
|
||||
|
||||
#### Docker
|
||||
|
||||
The Docker repository can be viewed [here](https://hub.docker.com/r/defendore/pseudotv).
|
||||
|
||||
Use Docker to fetch PseudoTV, then run the container.. (replace `C:\.pseudotv` with your desired config directory location)
|
||||
```
|
||||
docker pull defendore/pseudotv
|
||||
docker run --name pseudotv -p 8000:8000 -v C:\.pseudotv:/home/node/app/.pseudotv defendore/pseudotv
|
||||
```
|
||||
|
||||
#### Building Docker image from source
|
||||
|
||||
Build docker image from source and run the container. (replace `C:\.pseudotv` with your desired config directory location)
|
||||
|
||||
```
|
||||
git clone https://github.com/DEFENDORe/pseudotv
|
||||
cd pseudotv-plex
|
||||
docker build -t pseudotv .
|
||||
docker run --name pseudotv -p 8000:8000 -v C:\.pseudotv:/home/node/app/.pseudotv pseudotv
|
||||
```
|
||||
|
||||
#### Unraid Install
|
||||
Add
|
||||
```
|
||||
https://github.com/DEFENDORe/pseudotv
|
||||
```
|
||||
to your "Template repositories" in the Docker tab.
|
||||
Click the "Add Container" button
|
||||
Select either the pseudotv template or the pseudotv-nvidia template if you want nvidia hardware accelerated transcoding.
|
||||
Make sure you have the Unraid Nvidia plugin installed and change your video encoder to h264_nvenc in the pseudotv ffmpeg settings.
|
||||
|
||||
#### From Source
|
||||
|
||||
Install NodeJS and FFMPEG
|
||||
|
||||
```
|
||||
git clone https://github.com/DEFENDORe/pseudotv
|
||||
cd pseudotv-plex
|
||||
npm install
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Plex Setup
|
||||
|
||||
Add the PseudoTV spoofed HDHomerun tuner to Plex via Plex Settings.
|
||||
|
||||
If the tuner isn't automatically listed, manually enter the network address of pseudotv. Example:
|
||||
```
|
||||
127.0.0.1:8000
|
||||
```
|
||||
|
||||
When prompted for a Postal/Zip code, click the `"Have an XMLTV guide on your server? Click here to use that instead."` link.
|
||||
|
||||
Enter the location of the `.pseudotv/xmltv.xml` file. Example (Windows):
|
||||
```
|
||||
C:\.pseudotv\xmltv.xml
|
||||
```
|
||||
|
||||
**Do not use the Web UI XMLTV URL when feeding Plex the xmltv.xml file. Plex fails to update it's EPG from a URL for some reason (at least on Windows). Use the local file path to `.pseudotv/xmltv.xml`**
|
||||
|
||||
## App Preview
|
||||
<img src="./docs/channels.png" width="500">
|
||||
<img src="https://raw.githubusercontent.com/vexorian/dizquetv/main/docs/channels.png" width="500">
|
||||
<br/>
|
||||
<img src="./docs/channel-config.png" width="500">
|
||||
<img src="https://raw.githubusercontent.com/vexorian/dizquetv/main/docs/channel-config.png" width="500">
|
||||
<br/>
|
||||
<img src="./docs/plex-guide.png" width="500">
|
||||
<img src="https://raw.githubusercontent.com/vexorian/dizquetv/main/docs/plex-guide.png" width="500">
|
||||
<br/>
|
||||
<img src="./docs/plex-stream.png" width="500">
|
||||
<img src="https://raw.githubusercontent.com/vexorian/dizquetv/main/docs/plex-stream.png" width="500">
|
||||
|
||||
## Development
|
||||
Building/Packaging Binaries: (uses `browserify`, `babel` and `pkg`)
|
||||
@ -151,3 +66,24 @@ Live Development: (using `nodemon` and `watchify`)
|
||||
npm run dev-client
|
||||
npm run dev-server
|
||||
```
|
||||
|
||||
## Contribute
|
||||
|
||||
* Pull requests welcome but please read the [Code of Conduct](CODE_OF_CONDUCT.md) and the [Pull Request Template](pull_request_template.md) first.
|
||||
* Tip Jar: https://buymeacoffee.com/vexorian
|
||||
|
||||
## License
|
||||
|
||||
* Original pseudotv-Plex code was released under [MIT license (c) 2020 Dan Ferguson](https://github.com/DEFENDORe/pseudotv/blob/665e71e24ee5e93d9c9c90545addb53fdc235ff6/LICENSE)
|
||||
* dizqueTV's improvements are released under zlib license (c) 2020 Victor Hugo Soliz Kuncar
|
||||
* FontAwesome: [https://fontawesome.com/license/free](https://archive.fo/PRqis)
|
||||
* Bootstrap: https://github.com/twbs/bootstrap/blob/v4.4.1/LICENSE
|
||||
|
||||
## Thanks
|
||||
|
||||
* DEFENDORe , George and everyone that worked on PseudoTV
|
||||
* Ahmed Said Al-Busaidi , for reporting exploits in advance before publishing in exploit-db.
|
||||
* Nathan for working on automation addons.
|
||||
* Timebomb, Rafael for the contributions during dizqueTV's active days.
|
||||
|
||||
|
||||
1
commitlint.config.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = {extends: ['@commitlint/config-conventional']}
|
||||
@ -1,30 +1,30 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>pseudotv</Name>
|
||||
<Repository>defendore/pseudotv:latest</Repository>
|
||||
<Registry>https://hub.docker.com/r/defendore/pseudotv</Registry>
|
||||
<Name>dizquetv</Name>
|
||||
<Repository>vexorian/dizquetv:latest-nvidia</Repository>
|
||||
<Registry>https://hub.docker.com/r/vexorian/dizquetv</Registry>
|
||||
<Network>host</Network>
|
||||
<MyIP/>
|
||||
<Shell>bash</Shell>
|
||||
<Privileged>false</Privileged>
|
||||
<Support/>
|
||||
<Project/>
|
||||
<Overview>PseudoTV is a Plex DVR plugin. It allows you to host your own fake live tv service by dynamically streaming media from your Plex servers(s). Your channels and settings are all manged throught the PseudoTV Web UI.
|
||||
<Overview>dizqueTV is a Plex DVR plugin. It allows you to host your own fake live tv service by dynamically streaming media from your Plex servers(s). Your channels and settings are all managed through the dizqueTV Web UI.
|
||||

|
||||
PseudoTV will show up as a HDHomeRun device within Plex. When configuring your Plex Tuner, simply use the generatered ./.pseudotv/xmltv.xml file for EPG data. PseudoTV will automatically refresh your Plex server's EPG data and channel mappings (if specified to do so in settings) when configuring channels via the Web UI. Ensure your FFMPEG path is set correctly via the Web UI, and enjoy!</Overview>
|
||||
dizqueTV will show up as a HDHomeRun device within Plex. When configuring your Plex Tuner, simply use the generatered ./.dizquetv/xmltv.xml file for EPG data. dizqueTV will automatically refresh your Plex server's EPG data and channel mappings (if specified to do so in settings) when configuring channels via the Web UI. Ensure your FFMPEG path is set correctly via the Web UI, and enjoy!</Overview>
|
||||
<Category/>
|
||||
<WebUI>http://[IP]:[PORT:8000]</WebUI>
|
||||
<TemplateURL/>
|
||||
<Icon>https://raw.githubusercontent.com/DEFENDORe/pseudotv/master/resources/pseudotv.png</Icon>
|
||||
<Icon>https://raw.githubusercontent.com/vexorian/dizquetv/main/resources/dizquetv.png</Icon>
|
||||
<ExtraParams>--runtime=nvidia</ExtraParams>
|
||||
<PostArgs/>
|
||||
<CPUset/>
|
||||
<DateInstalled>1589436589</DateInstalled>
|
||||
<DonateText/>
|
||||
<DonateLink/>
|
||||
<Description>PseudoTV is a Plex DVR plugin. It allows you to host your own fake live tv service by dynamically streaming media from your Plex servers(s). Your channels and settings are all manged throught the PseudoTV Web UI.
|
||||
<Description>dizqueTV is a Plex DVR plugin. It allows you to host your own fake live tv service by dynamically streaming media from your Plex servers(s). Your channels and settings are all manged throught the dizqueTV Web UI.
|
||||

|
||||
PseudoTV will show up as a HDHomeRun device within Plex. When configuring your Plex Tuner, simply use the generatered ./.pseudotv/xmltv.xml file for EPG data. PseudoTV will automatically refresh your Plex server's EPG data and channel mappings (if specified to do so in settings) when configuring channels via the Web UI. Ensure your FFMPEG path is set correctly via the Web UI, and enjoy!</Description>
|
||||
dizqueTV will show up as a HDHomeRun device within Plex. When configuring your Plex Tuner, simply use the generatered ./.dizque/xmltv.xml file for EPG data. dizqueTV will automatically refresh your Plex server's EPG data and channel mappings (if specified to do so in settings) when configuring channels via the Web UI. Ensure your FFMPEG path is set correctly via the Web UI, and enjoy!</Description>
|
||||
<Networking>
|
||||
<Mode>host</Mode>
|
||||
<Publish>
|
||||
@ -37,8 +37,8 @@ PseudoTV will show up as a HDHomeRun device within Plex. When configuring your P
|
||||
</Networking>
|
||||
<Data>
|
||||
<Volume>
|
||||
<HostDir>/mnt/user/appdata/pseudotv/</HostDir>
|
||||
<ContainerDir>/home/node/app/.pseudotv</ContainerDir>
|
||||
<HostDir>/mnt/user/appdata/dizquetv/</HostDir>
|
||||
<ContainerDir>/home/node/app/.dizquetv</ContainerDir>
|
||||
<Mode>rw</Mode>
|
||||
</Volume>
|
||||
</Data>
|
||||
@ -58,5 +58,5 @@ PseudoTV will show up as a HDHomeRun device within Plex. When configuring your P
|
||||
<Config Name="Webui &amp; HDHR" Target="8000" Default="" Mode="tcp" Description="Container Port: 8000" Type="Port" Display="always" Required="false" Mask="false">8000</Config>
|
||||
<Config Name="NVIDIA_VISIBLE_DEVICES" Target="NVIDIA_VISIBLE_DEVICES" Default="" Mode="" Description="Container Variable: NVIDIA_VISIBLE_DEVICES" Type="Variable" Display="always" Required="false" Mask="false">all</Config>
|
||||
<Config Name="NVIDIA_DRIVER_CAPABILITIES" Target="NVIDIA_DRIVER_CAPABILITIES" Default="" Mode="" Description="Container Variable: NVIDIA_DRIVER_CAPABILITIES" Type="Variable" Display="always" Required="false" Mask="false">all</Config>
|
||||
<Config Name="Appdata" Target="/home/node/app/.pseudotv" Default="" Mode="rw" Description="Container Path: /home/node/app/.pseudotv" Type="Path" Display="always" Required="false" Mask="false">/mnt/user/appdata/pseudotv/</Config>
|
||||
<Config Name="Appdata" Target="/home/node/app/.dizquetv" Default="" Mode="rw" Description="Container Path: /home/node/app/.dizquetv" Type="Path" Display="always" Required="false" Mask="false">/mnt/user/appdata/dizquetv/</Config>
|
||||
</Container>
|
||||
@ -1,8 +1,8 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>pseudotv</Name>
|
||||
<Repository>defendore/pseudotv:latest</Repository>
|
||||
<Registry>https://hub.docker.com/r/defendore/pseudotv</Registry>
|
||||
<Name>dizquetv</Name>
|
||||
<Repository>vexorian/dizquetv:latest</Repository>
|
||||
<Registry>https://hub.docker.com/r/vexorian/dizquetv</Registry>
|
||||
<Network>host</Network>
|
||||
<MyIP/>
|
||||
<Shell>bash</Shell>
|
||||
@ -13,7 +13,7 @@
|
||||
<Category/>
|
||||
<WebUI>http://[IP]:[PORT:8000]</WebUI>
|
||||
<TemplateURL/>
|
||||
<Icon>https://raw.githubusercontent.com/DEFENDORe/pseudotv/master/resources/pseudotv.png</Icon>
|
||||
<Icon>https://raw.githubusercontent.com/vexorian/dizquetv/main/resources/dizquetv.png</Icon>
|
||||
<ExtraParams></ExtraParams>
|
||||
<PostArgs/>
|
||||
<CPUset/>
|
||||
@ -33,8 +33,8 @@
|
||||
</Networking>
|
||||
<Data>
|
||||
<Volume>
|
||||
<HostDir>/mnt/user/appdata/pseudotv/</HostDir>
|
||||
<ContainerDir>/home/node/app/.pseudotv</ContainerDir>
|
||||
<HostDir>/mnt/user/appdata/dizquetv/</HostDir>
|
||||
<ContainerDir>/home/node/app/.dizquetv</ContainerDir>
|
||||
<Mode>rw</Mode>
|
||||
</Volume>
|
||||
</Data>
|
||||
@ -42,5 +42,5 @@
|
||||
</Environment>
|
||||
<Labels/>
|
||||
<Config Name="Webui &amp; HDHR" Target="8000" Default="" Mode="tcp" Description="Container Port: 8000" Type="Port" Display="always" Required="false" Mask="false">8000</Config>
|
||||
<Config Name="Appdata" Target="/home/node/app/.pseudotv" Default="" Mode="rw" Description="Container Path: /home/node/app/.pseudotv" Type="Path" Display="always" Required="false" Mask="false">/mnt/user/appdata/pseudotv/</Config>
|
||||
<Config Name="Appdata" Target="/home/node/app/.dizquetv" Default="" Mode="rw" Description="Container Path: /home/node/app/.dizquetv" Type="Path" Display="always" Required="false" Mask="false">/mnt/user/appdata/dizquetv/</Config>
|
||||
</Container>
|
||||
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 665 KiB |
|
Before Width: | Height: | Size: 869 KiB After Width: | Height: | Size: 735 KiB |
15
locales/server/en.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"event":{
|
||||
"server_started": "Server Started",
|
||||
"server_shutdown": "Initiated Server Shutdown"
|
||||
},
|
||||
"api": {
|
||||
"plex_server_not_found": "Plex server not found.",
|
||||
"missing_name": "Missing name"
|
||||
},
|
||||
"tvGuide": {
|
||||
"no_channels": "No channels configured",
|
||||
"no_channels_summary": "Use the dizqueTV web UI to configure channels.",
|
||||
"xmltv_updated": "XMLTV updated at server time {{t}}"
|
||||
}
|
||||
}
|
||||
34
make_dist.sh
Normal file
@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
MODE=${1:-all}
|
||||
WIN64=dizquetv-win-x64.exe
|
||||
WIN32=dizquetv-win-x86.exe
|
||||
MACOSX=dizquetv-macos-x64
|
||||
LINUX64=${LINUXBUILD:-dizquetv-linux-x64}
|
||||
|
||||
rm -R ./dist/*
|
||||
npm run build || exit 1
|
||||
npm run compile || exit 1
|
||||
cp -R ./web ./dist/web
|
||||
cp -R ./resources ./dist/
|
||||
cp -R ./locales/ ./dist/locales/
|
||||
cd dist
|
||||
if [ "$MODE" == "all" ]; then
|
||||
nexe --temp /var/nexe -r "./**/*" -t windows-x64-12.18.2 --output $WIN64
|
||||
mv $WIN64 ../
|
||||
nexe --temp /var/nexe -r "./**/*" -t mac-x64-12.18.2 --output $MACOSX
|
||||
mv $MACOSX ../
|
||||
nexe --temp /var/nexe -r "./**/*" -t windows-x86-12.18.2 --output $WIN32
|
||||
mv $WIN32 ../
|
||||
fi
|
||||
|
||||
nexe --temp /var/nexe -r "./**/*" -t linux-x64-12.16.2 --output $LINUX64 || exit 1
|
||||
echo dist/$LINUX64
|
||||
if [ "$MODE" == "all" ]; then
|
||||
mv ../$WIN64 ./
|
||||
mv ../$WIN32 ./
|
||||
mv ../$MACOSX ./
|
||||
echo dist/$WIN64
|
||||
echo dist/$MACOSX
|
||||
echo dist/$WIN32
|
||||
fi
|
||||
|
||||
7351
package-lock.json
generated
48
package.json
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "pseudotv",
|
||||
"name": "dizquetv",
|
||||
"version": "1.0.0",
|
||||
"description": "Create LiveTV channels from your Plex media",
|
||||
"main": "index.js",
|
||||
@ -10,46 +10,50 @@
|
||||
"dev-client": "watchify ./web/app.js -o ./web/public/bundle.js",
|
||||
"dev-server": "nodemon index.js --ignore ./web/ --ignore ./db/ --ignore ./xmltv.xml",
|
||||
"compile": "babel index.js -d dist && babel src -d dist/src",
|
||||
"package": "copyfiles ./web/public/**/* ./dist && copyfiles ./resources/**/* ./dist && pkg . --out-path bin",
|
||||
"clean": "del-cli --force ./bin ./dist ./.pseudotv ./web/public/bundle.js"
|
||||
"package": "sh ./make_dist.sh",
|
||||
"clean": "del-cli --force ./bin ./dist ./.dizquetv ./web/public/bundle.js"
|
||||
},
|
||||
"author": "Dan Ferguson",
|
||||
"license": "ISC",
|
||||
"author": "vexorian",
|
||||
"license": "Zlib",
|
||||
"dependencies": {
|
||||
"angular": "^1.7.9",
|
||||
"angular": "^1.8.0",
|
||||
"angular-router-browserify": "0.0.2",
|
||||
"axios": "^0.19.2",
|
||||
"angular-sanitize": "^1.8.2",
|
||||
"angular-vs-repeat": "2.0.13",
|
||||
"axios": "^0.21.1",
|
||||
"body-parser": "^1.19.0",
|
||||
"diskdb": "^0.1.17",
|
||||
"diskdb": "0.1.17",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"i18next": "^20.3.2",
|
||||
"i18next-fs-backend": "^1.1.1",
|
||||
"i18next-http-backend": "^1.2.6",
|
||||
"i18next-http-middleware": "^3.1.4",
|
||||
"JSONStream": "1.0.5",
|
||||
"merge": "2.1.1",
|
||||
"ng-i18next": "^1.0.7",
|
||||
"node-graceful-shutdown": "1.1.0",
|
||||
"node-ssdp": "^4.0.0",
|
||||
"quickselect": "2.0.0",
|
||||
"random-js": "2.1.0",
|
||||
"request": "^2.88.2",
|
||||
"uuid": "^8.0.0",
|
||||
"uuid": "9.0.1",
|
||||
"unzipper": "0.10.14",
|
||||
"xml-writer": "^1.7.0"
|
||||
},
|
||||
"bin": "dist/index.js",
|
||||
"pkg": {
|
||||
"assets": [
|
||||
"dist/web/public/**/*",
|
||||
"dist/resources/**/*"
|
||||
],
|
||||
"targets": [
|
||||
"x64",
|
||||
"linux",
|
||||
"macos",
|
||||
"windows"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.8.4",
|
||||
"@babel/core": "^7.9.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||
"@babel/preset-env": "^7.9.5",
|
||||
"@commitlint/cli": "^12.1.4",
|
||||
"@commitlint/config-conventional": "^12.1.4",
|
||||
"browserify": "^16.5.1",
|
||||
"copyfiles": "^2.2.0",
|
||||
"del-cli": "^3.0.0",
|
||||
"nexe": "^3.3.7",
|
||||
"nodemon": "^2.0.3",
|
||||
"pkg": "^4.4.7",
|
||||
"watchify": "^3.11.1"
|
||||
},
|
||||
"babel": {
|
||||
|
||||
25
pull_request_template.md
Normal file
@ -0,0 +1,25 @@
|
||||
### Explanation of the changes, problem that they are intended to fix.
|
||||
|
||||
...
|
||||
|
||||
### All Submissions:
|
||||
|
||||
* [ ] I have read the code of conduct.
|
||||
* [ ] I am submitting to the correct base branch
|
||||
<!--
|
||||
* Bug fixes for 'stable' versions must go to `patch`.
|
||||
* New features and fixes for 'edge' version must go to `development`.
|
||||
-->
|
||||
### Changes that modify the db structure
|
||||
|
||||
* [ ] Backwards compatibility: Users running the new code using an existing .disquetv folder will not lose their channels / settings.
|
||||
* [ ] I've implemented the necessary db migration steps if any.
|
||||
|
||||
### New features
|
||||
|
||||
* [ ] I understand that the feature may not be accepted if it doesn't fit the upstream app's planned design direction. But that in this case I am encouraged to share this as an available modification other users can use if they want.
|
||||
|
||||
### Code Standards
|
||||
|
||||
* [ ] I understand the code being contributed and it's purpose. <!-- Please read CODE_OF_CONDUCT for more info. -->
|
||||
|
||||
BIN
resources/black.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
resources/bootstrap-4.4.1-dist.zip
Normal file
14
resources/default-custom.css
Normal file
@ -0,0 +1,14 @@
|
||||
/** For example : */
|
||||
|
||||
|
||||
|
||||
:root {
|
||||
--guide-text : #F0F0f0;
|
||||
--guide-header-even: #423cd4ff;
|
||||
--guide-header-odd: #262198ff;
|
||||
--guide-color-a: #212121;
|
||||
--guide-color-b: #515151;
|
||||
--guide-color-c: #313131;
|
||||
--guide-color-d: #414141;
|
||||
}
|
||||
|
||||
BIN
resources/dizquetv.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
136
resources/favicon.svg
Normal file
@ -0,0 +1,136 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="200"
|
||||
height="200"
|
||||
viewBox="0 0 52.9168 52.916668"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||
sodipodi:docname="favicon.svg"
|
||||
inkscape:export-filename="/home/vx/dev/pseudotv/resources/favicon-16.png"
|
||||
inkscape:export-xdpi="7.6799998"
|
||||
inkscape:export-ydpi="7.6799998">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="3.0547013"
|
||||
inkscape:cx="55.816079"
|
||||
inkscape:cy="84.726326"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1056"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="24"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Capa 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-244.08278)">
|
||||
<rect
|
||||
style="opacity:1;fill:#a1a1a1;fill-opacity:0.86666667;stroke:none;stroke-width:1.46508551;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect4712"
|
||||
width="52.211964"
|
||||
height="51.512306"
|
||||
x="-0.85796964"
|
||||
y="245.32475"
|
||||
transform="matrix(0.99980416,-0.01978974,0.00448328,0.99998995,0,0)" />
|
||||
<g
|
||||
id="g4581"
|
||||
style="fill:#080808;fill-opacity:1;stroke-width:0.68901283"
|
||||
transform="matrix(1.2119871,0,0,1.7379906,-82.577875,-167.18505)">
|
||||
<rect
|
||||
transform="rotate(-0.94645665)"
|
||||
y="239.28041"
|
||||
x="65.156158"
|
||||
height="27.75024"
|
||||
width="41.471352"
|
||||
id="rect4524"
|
||||
style="opacity:1;fill:#080808;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
</g>
|
||||
<rect
|
||||
style="opacity:1;fill:#9cbc28;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect4518"
|
||||
width="10.338528"
|
||||
height="23.042738"
|
||||
x="8.726779"
|
||||
y="258.22861"
|
||||
transform="matrix(0.99995865,0.00909414,-0.00926779,0.99995705,0,0)" />
|
||||
<ellipse
|
||||
style="opacity:1;fill:#a1a1a1;fill-opacity:0.86792453;stroke:none;stroke-width:1.46499991;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="path4568"
|
||||
cx="44.118061"
|
||||
cy="261.20392"
|
||||
rx="2.4216392"
|
||||
ry="2.3988426" />
|
||||
<ellipse
|
||||
cy="272.90894"
|
||||
cx="44.765343"
|
||||
id="circle4570"
|
||||
style="opacity:1;fill:#a1a1a1;fill-opacity:0.86792453;stroke:none;stroke-width:1.46499991;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
rx="2.4216392"
|
||||
ry="2.3988426" />
|
||||
<g
|
||||
id="g1705"
|
||||
transform="translate(0,-2.116672)">
|
||||
<rect
|
||||
transform="matrix(0.99967585,0.02545985,-0.02594573,0.99966335,0,0)"
|
||||
style="opacity:1;fill:#289bbc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect4520"
|
||||
width="10.338468"
|
||||
height="23.042864"
|
||||
x="23.424755"
|
||||
y="259.99872" />
|
||||
</g>
|
||||
<rect
|
||||
transform="matrix(0.99837418,-0.05699994,0.05808481,0.99831165,0,0)"
|
||||
y="259.69229"
|
||||
x="10.517879"
|
||||
height="23.043449"
|
||||
width="10.33821"
|
||||
id="rect4522"
|
||||
style="opacity:1;fill:#bc289b;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:2.03992438px;line-height:125%;font-family:'Liberation Serif';-inkscape-font-specification:'Liberation Serif';letter-spacing:0px;word-spacing:0px;fill:#e6e6e6;fill-opacity:1;stroke:none;stroke-width:0.264584px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="16.979799"
|
||||
y="286.34747"
|
||||
id="text1730"
|
||||
transform="rotate(-1.2296789)"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan1728"
|
||||
x="16.979799"
|
||||
y="286.34747"
|
||||
style="fill:#e6e6e6;fill-opacity:1;stroke-width:0.264584px">dizqueTV</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
BIN
resources/fontawesome-free-5.15.4-web.zip
Normal file
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 54 KiB |
BIN
resources/generic-music-screen.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 16 KiB |
BIN
resources/loading-screen.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 35 KiB |
999
src/api.js
@ -1,23 +1,70 @@
|
||||
const SLACK = require('./constants').SLACK;
|
||||
|
||||
let cache = {};
|
||||
let programPlayTimeCache = {};
|
||||
let configCache = {};
|
||||
|
||||
function getChannelConfig(db, channelId) {
|
||||
let fillerPlayTimeCache = {};
|
||||
let configCache = {};
|
||||
let numbers = null;
|
||||
|
||||
async function getChannelConfig(channelDB, channelId) {
|
||||
//with lazy-loading
|
||||
|
||||
if ( typeof(configCache[channelId]) === 'undefined') {
|
||||
let channel = db['channels'].find( { number: channelId } )
|
||||
configCache[channelId] = channel;
|
||||
return channel;
|
||||
} else {
|
||||
return configCache[channelId];
|
||||
let channel = await channelDB.getChannel(channelId)
|
||||
if (channel == null) {
|
||||
configCache[channelId] = [];
|
||||
} else {
|
||||
configCache[channelId] = [channel];
|
||||
}
|
||||
}
|
||||
return configCache[channelId];
|
||||
}
|
||||
|
||||
async function getAllNumbers(channelDB) {
|
||||
if (numbers === null) {
|
||||
let n = await channelDB.getAllChannelNumbers();
|
||||
numbers = n;
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
|
||||
async function getAllChannels(channelDB) {
|
||||
let channelNumbers = await getAllNumbers(channelDB);
|
||||
return (await Promise.all( channelNumbers.map( async (x) => {
|
||||
return (await getChannelConfig(channelDB, x))[0];
|
||||
}) )).filter( (channel) => {
|
||||
if (channel == null) {
|
||||
console.error("Found a null channel " + JSON.stringify(channelNumbers) );
|
||||
return false;
|
||||
}
|
||||
if ( typeof(channel) === "undefined") {
|
||||
console.error("Found a undefined channel " + JSON.stringify(channelNumbers) );
|
||||
return false;
|
||||
}
|
||||
if ( typeof(channel.number) === "undefined") {
|
||||
console.error("Found a channel without number " + JSON.stringify(channelNumbers) );
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} );
|
||||
}
|
||||
|
||||
|
||||
function saveChannelConfig(number, channel ) {
|
||||
configCache[number] = [channel];
|
||||
|
||||
// flush the item played cache for the channel and any channel in its
|
||||
// redirect chain
|
||||
if (typeof(cache[number]) !== 'undefined') {
|
||||
let lineupItem = cache[number].lineupItem;
|
||||
for (let i = 0; i < lineupItem.redirectChannels.length; i++) {
|
||||
delete cache[ lineupItem.redirectChannels[i].number ];
|
||||
}
|
||||
delete cache[number];
|
||||
|
||||
}
|
||||
numbers = null;
|
||||
}
|
||||
|
||||
function getCurrentLineupItem(channelId, t1) {
|
||||
@ -27,10 +74,21 @@ function getCurrentLineupItem(channelId, t1) {
|
||||
let recorded = cache[channelId];
|
||||
let lineupItem = JSON.parse( JSON.stringify(recorded.lineupItem) );
|
||||
let diff = t1 - recorded.t0;
|
||||
if (diff <= SLACK) {
|
||||
let rem = lineupItem.duration - lineupItem.start;
|
||||
if (typeof(lineupItem.streamDuration) !== 'undefined') {
|
||||
rem = Math.min(rem, lineupItem.streamDuration);
|
||||
}
|
||||
if ( (diff <= SLACK) && (diff + SLACK < rem) ) {
|
||||
//closed the stream and opened it again let's not lose seconds for
|
||||
//no reason
|
||||
return lineupItem;
|
||||
let originalT0 = recorded.lineupItem.originalT0;
|
||||
if (typeof(originalT0) === 'undefined') {
|
||||
originalT0 = recorded.t0;
|
||||
}
|
||||
if (t1 - originalT0 <= SLACK) {
|
||||
lineupItem.originalT0 = originalT0;
|
||||
return lineupItem;
|
||||
}
|
||||
}
|
||||
|
||||
lineupItem.start += diff;
|
||||
@ -40,40 +98,58 @@ function getCurrentLineupItem(channelId, t1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if(lineupItem.start + SLACK > lineupItem.actualDuration) {
|
||||
if(lineupItem.start + SLACK > lineupItem.duration) {
|
||||
return null;
|
||||
}
|
||||
return lineupItem;
|
||||
}
|
||||
|
||||
function getKey(channelId, program) {
|
||||
function getProgramKey(program) {
|
||||
let serverKey = "!unknown!";
|
||||
if (typeof(program.server) !== 'undefined') {
|
||||
if (typeof(program.server.name) !== 'undefined') {
|
||||
serverKey = "plex|" + program.server.name;
|
||||
if (typeof(program.serverKey) !== 'undefined') {
|
||||
if (typeof(program.serverKey) !== 'undefined') {
|
||||
serverKey = "plex|" + program.serverKey;
|
||||
}
|
||||
}
|
||||
let programKey = "!unknownProgram!";
|
||||
if (typeof(program.key) !== 'undefined') {
|
||||
programKey = program.key;
|
||||
}
|
||||
return channelId + "|" + serverKey + "|" + programKey;
|
||||
|
||||
return serverKey + "|" + programKey;
|
||||
}
|
||||
|
||||
|
||||
function recordProgramPlayTime(channelId, lineupItem, t0) {
|
||||
function getFillerKey(channelId, fillerId) {
|
||||
return channelId + "|" + fillerId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function recordProgramPlayTime(programPlayTime, channelId, lineupItem, t0) {
|
||||
let remaining;
|
||||
if ( typeof(lineupItem.streamDuration) !== 'undefined') {
|
||||
remaining = lineupItem.streamDuration;
|
||||
} else {
|
||||
remaining = lineupItem.actualDuration - lineupItem.start;
|
||||
remaining = lineupItem.duration - lineupItem.start;
|
||||
}
|
||||
setProgramLastPlayTime(programPlayTime, channelId, lineupItem, t0 + remaining);
|
||||
if (typeof(lineupItem.fillerId) !== 'undefined') {
|
||||
fillerPlayTimeCache[ getFillerKey(channelId, lineupItem.fillerId) ] = t0 + remaining;
|
||||
}
|
||||
programPlayTimeCache[ getKey(channelId, lineupItem) ] = t0 + remaining;
|
||||
}
|
||||
|
||||
function getProgramLastPlayTime(channelId, program) {
|
||||
let v = programPlayTimeCache[ getKey(channelId, program) ];
|
||||
function setProgramLastPlayTime(programPlayTime, channelId, lineupItem, t) {
|
||||
let programKey = getProgramKey(lineupItem);
|
||||
programPlayTime.update(channelId, programKey, t);
|
||||
}
|
||||
|
||||
function getProgramLastPlayTime(programPlayTime, channelId, program) {
|
||||
let programKey = getProgramKey(program);
|
||||
return programPlayTime.getProgramLastPlayTime(channelId, programKey);
|
||||
}
|
||||
|
||||
function getFillerLastPlayTime(channelId, fillerId) {
|
||||
let v = fillerPlayTimeCache[ getFillerKey(channelId, fillerId) ];
|
||||
if (typeof(v) === 'undefined') {
|
||||
return 0;
|
||||
} else {
|
||||
@ -81,8 +157,8 @@ function getProgramLastPlayTime(channelId, program) {
|
||||
}
|
||||
}
|
||||
|
||||
function recordPlayback(channelId, t0, lineupItem) {
|
||||
recordProgramPlayTime(channelId, lineupItem, t0);
|
||||
function recordPlayback(programPlayTime, channelId, t0, lineupItem) {
|
||||
recordProgramPlayTime(programPlayTime, channelId, lineupItem, t0);
|
||||
|
||||
cache[channelId] = {
|
||||
t0: t0,
|
||||
@ -90,10 +166,15 @@ function recordPlayback(channelId, t0, lineupItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearPlayback(channelId) {
|
||||
delete cache[channelId];
|
||||
}
|
||||
|
||||
function clear() {
|
||||
//it's not necessary to clear the playback cache and it may be undesirable
|
||||
configCache = {};
|
||||
cache = {};
|
||||
numbers = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@ -101,6 +182,10 @@ module.exports = {
|
||||
recordPlayback: recordPlayback,
|
||||
clear: clear,
|
||||
getProgramLastPlayTime: getProgramLastPlayTime,
|
||||
getAllChannels: getAllChannels,
|
||||
getAllNumbers: getAllNumbers,
|
||||
getChannelConfig: getChannelConfig,
|
||||
saveChannelConfig: saveChannelConfig,
|
||||
}
|
||||
getFillerLastPlayTime: getFillerLastPlayTime,
|
||||
clearPlayback: clearPlayback,
|
||||
}
|
||||
|
||||
@ -1,4 +1,39 @@
|
||||
module.exports = {
|
||||
SLACK: 9999,
|
||||
VERSION_NAME: "0.0.54-testing"
|
||||
}
|
||||
TVGUIDE_MAXIMUM_PADDING_LENGTH_MS: 30*60*1000,
|
||||
DEFAULT_GUIDE_STEALTH_DURATION: 5 * 60* 1000,
|
||||
TVGUIDE_MAXIMUM_FLEX_DURATION : 6 * 60 * 60 * 1000,
|
||||
TOO_FREQUENT: 1000,
|
||||
|
||||
// Duration of things like the loading screen and the interlude (the black
|
||||
// frame that appears between videos). The goal of these things is to
|
||||
// prevent the video from getting stuck on the last second, which looks bad
|
||||
// for some reason ~750 works well. I raised the fps to 60 and now 420 works
|
||||
// but I wish it was lower.
|
||||
GAP_DURATION: 10*42,
|
||||
|
||||
//when a channel is forcibly stopped due to an update, let's mark it as active
|
||||
// for a while during the transaction just in case.
|
||||
CHANNEL_STOP_SHIELD : 5000,
|
||||
|
||||
START_CHANNEL_GRACE_PERIOD: 15 * 1000,
|
||||
|
||||
// if a channel is stopped while something is playing, subtract
|
||||
// this amount of milliseconds from the last-played timestamp, because
|
||||
// video playback has latency and also because maybe the user wants
|
||||
// the last 30 seconds to remember what was going on...
|
||||
FORGETFULNESS_BUFFER: 30 * 1000,
|
||||
|
||||
// When a channel stops playing, this is a grace period before the channel is
|
||||
// considered offline. It could be that the client halted the playback for some
|
||||
// reason and is about to start playing again. Or maybe the user switched
|
||||
// devices or something. Otherwise we would have on-demand channels constantly
|
||||
// reseting on their own.
|
||||
MAX_CHANNEL_IDLE: 60*1000,
|
||||
|
||||
// there's a timer that checks all active channels to see if they really are
|
||||
// staying active, it checks every 5 seconds
|
||||
PLAYED_MONITOR_CHECK_FREQUENCY: 5*1000,
|
||||
|
||||
VERSION_NAME: "1.5.5"
|
||||
}
|
||||
|
||||
124
src/dao/channel-db.js
Normal file
@ -0,0 +1,124 @@
|
||||
const path = require('path');
|
||||
var fs = require('fs');
|
||||
|
||||
class ChannelDB {
|
||||
|
||||
constructor(folder) {
|
||||
this.folder = folder;
|
||||
}
|
||||
|
||||
async getChannel(number) {
|
||||
let f = path.join(this.folder, `${number}.json` );
|
||||
try {
|
||||
return await new Promise( (resolve, reject) => {
|
||||
fs.readFile(f, (err, data) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
try {
|
||||
resolve( JSON.parse(data) )
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveChannel(number, json) {
|
||||
await this.validateChannelJson(number, json);
|
||||
let f = path.join(this.folder, `${json.number}.json` );
|
||||
return await new Promise( (resolve, reject) => {
|
||||
let data = undefined;
|
||||
try {
|
||||
data = JSON.stringify(json);
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
fs.writeFile(f, data, (err) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
saveChannelSync(number, json) {
|
||||
this.validateChannelJson(number, json);
|
||||
|
||||
let data = JSON.stringify(json);
|
||||
let f = path.join(this.folder, `${json.number}.json` );
|
||||
fs.writeFileSync( f, data );
|
||||
}
|
||||
|
||||
validateChannelJson(number, json) {
|
||||
json.number = number;
|
||||
if (typeof(json.number) === 'undefined') {
|
||||
throw Error("Expected a channel.number");
|
||||
}
|
||||
if (typeof(json.number) === 'string') {
|
||||
try {
|
||||
json.number = parseInt(json.number);
|
||||
} catch (err) {
|
||||
console.error("Error parsing channel number.", err);
|
||||
}
|
||||
}
|
||||
if ( isNaN(json.number)) {
|
||||
throw Error("channel.number must be a integer");
|
||||
}
|
||||
}
|
||||
|
||||
async deleteChannel(number) {
|
||||
let f = path.join(this.folder, `${number}.json` );
|
||||
await new Promise( (resolve, reject) => {
|
||||
fs.unlink(f, function (err) {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getAllChannelNumbers() {
|
||||
return await new Promise( (resolve, reject) => {
|
||||
fs.readdir(this.folder, function(err, items) {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
let channelNumbers = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let name = path.basename( items[i] );
|
||||
if (path.extname(name) === '.json') {
|
||||
let numberStr = name.slice(0, -5);
|
||||
if (!isNaN(numberStr)) {
|
||||
channelNumbers.push( parseInt(numberStr) );
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve (channelNumbers);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getAllChannels() {
|
||||
let numbers = await this.getAllChannelNumbers();
|
||||
return await Promise.all( numbers.map( async (c) => this.getChannel(c) ) );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
module.exports = ChannelDB;
|
||||
131
src/dao/custom-show-db.js
Normal file
@ -0,0 +1,131 @@
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
let fs = require('fs');
|
||||
|
||||
class CustomShowDB {
|
||||
|
||||
constructor(folder) {
|
||||
this.folder = folder;
|
||||
}
|
||||
|
||||
async $loadShow(id) {
|
||||
let f = path.join(this.folder, `${id}.json` );
|
||||
try {
|
||||
return await new Promise( (resolve, reject) => {
|
||||
fs.readFile(f, (err, data) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
try {
|
||||
let j = JSON.parse(data);
|
||||
j.id = id;
|
||||
resolve(j);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getShow(id) {
|
||||
return await this.$loadShow(id);
|
||||
}
|
||||
|
||||
async saveShow(id, json) {
|
||||
if (typeof(id) === 'undefined') {
|
||||
throw Error("Mising custom show id");
|
||||
}
|
||||
let f = path.join(this.folder, `${id}.json` );
|
||||
|
||||
await new Promise( (resolve, reject) => {
|
||||
let data = undefined;
|
||||
try {
|
||||
//id is determined by the file name, not the contents
|
||||
fixup(json);
|
||||
delete json.id;
|
||||
data = JSON.stringify(json);
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
fs.writeFile(f, data, (err) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async createShow(json) {
|
||||
let id = uuidv4();
|
||||
fixup(json);
|
||||
await this.saveShow(id, json);
|
||||
return id;
|
||||
}
|
||||
|
||||
async deleteShow(id) {
|
||||
let f = path.join(this.folder, `${id}.json` );
|
||||
await new Promise( (resolve, reject) => {
|
||||
fs.unlink(f, function (err) {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async getAllShowIds() {
|
||||
return await new Promise( (resolve, reject) => {
|
||||
fs.readdir(this.folder, function(err, items) {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
let fillerIds = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let name = path.basename( items[i] );
|
||||
if (path.extname(name) === '.json') {
|
||||
let id = name.slice(0, -5);
|
||||
fillerIds.push(id);
|
||||
}
|
||||
}
|
||||
resolve (fillerIds);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getAllShows() {
|
||||
let ids = await this.getAllShowIds();
|
||||
return await Promise.all( ids.map( async (c) => this.getShow(c) ) );
|
||||
}
|
||||
|
||||
async getAllShowsInfo() {
|
||||
//returns just name and id
|
||||
let shows = await this.getAllShows();
|
||||
return shows.map( (f) => {
|
||||
return {
|
||||
'id' : f.id,
|
||||
'name': f.name,
|
||||
'count': f.content.length,
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
function fixup(json) {
|
||||
if (typeof(json.content) === 'undefined') {
|
||||
json.content = [];
|
||||
}
|
||||
if (typeof(json.name) === 'undefined') {
|
||||
json.name = "Unnamed Show";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CustomShowDB;
|
||||
202
src/dao/filler-db.js
Normal file
@ -0,0 +1,202 @@
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
let fs = require('fs');
|
||||
|
||||
class FillerDB {
|
||||
|
||||
constructor(folder, channelService) {
|
||||
this.folder = folder;
|
||||
this.cache = {};
|
||||
this.channelService = channelService;
|
||||
|
||||
|
||||
}
|
||||
|
||||
async $loadFiller(id) {
|
||||
let f = path.join(this.folder, `${id}.json` );
|
||||
try {
|
||||
return await new Promise( (resolve, reject) => {
|
||||
fs.readFile(f, (err, data) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
try {
|
||||
let j = JSON.parse(data);
|
||||
j.id = id;
|
||||
resolve(j);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getFiller(id) {
|
||||
if (typeof(this.cache[id]) === 'undefined') {
|
||||
this.cache[id] = await this.$loadFiller(id);
|
||||
}
|
||||
return this.cache[id];
|
||||
}
|
||||
|
||||
async saveFiller(id, json) {
|
||||
if (typeof(id) === 'undefined') {
|
||||
throw Error("Mising filler id");
|
||||
}
|
||||
let f = path.join(this.folder, `${id}.json` );
|
||||
try {
|
||||
await new Promise( (resolve, reject) => {
|
||||
let data = undefined;
|
||||
try {
|
||||
//id is determined by the file name, not the contents
|
||||
fixup(json);
|
||||
delete json.id;
|
||||
data = JSON.stringify(json);
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
fs.writeFile(f, data, (err) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
delete this.cache[id];
|
||||
}
|
||||
}
|
||||
|
||||
async createFiller(json) {
|
||||
let id = uuidv4();
|
||||
fixup(json);
|
||||
await this.saveFiller(id, json);
|
||||
return id;
|
||||
}
|
||||
|
||||
async getFillerChannels(id) {
|
||||
let numbers = await this.channelService.getAllChannelNumbers();
|
||||
let channels = [];
|
||||
await Promise.all( numbers.map( async(number) => {
|
||||
let ch = await this.channelService.getChannel(number);
|
||||
let name = ch.name;
|
||||
let fillerCollections = ch.fillerCollections;
|
||||
for (let i = 0 ; i < fillerCollections.length; i++) {
|
||||
if (fillerCollections[i].id === id) {
|
||||
channels.push( {
|
||||
number: number,
|
||||
name : name,
|
||||
} );
|
||||
break;
|
||||
}
|
||||
}
|
||||
ch = null;
|
||||
|
||||
} ) );
|
||||
return channels;
|
||||
}
|
||||
|
||||
async deleteFiller(id) {
|
||||
try {
|
||||
let channels = await this.getFillerChannels(id);
|
||||
await Promise.all( channels.map( async(channel) => {
|
||||
console.log(`Updating channel ${channel.number} , remove filler: ${id}`);
|
||||
let json = await channelService.getChannel(channel.number);
|
||||
json.fillerCollections = json.fillerCollections.filter( (col) => {
|
||||
return col.id != id;
|
||||
} );
|
||||
await this.channelService.saveChannel( channel.number, json );
|
||||
} ) );
|
||||
|
||||
let f = path.join(this.folder, `${id}.json` );
|
||||
await new Promise( (resolve, reject) => {
|
||||
fs.unlink(f, function (err) {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
delete this.cache[id];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async getAllFillerIds() {
|
||||
return await new Promise( (resolve, reject) => {
|
||||
fs.readdir(this.folder, function(err, items) {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
let fillerIds = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let name = path.basename( items[i] );
|
||||
if (path.extname(name) === '.json') {
|
||||
let id = name.slice(0, -5);
|
||||
fillerIds.push(id);
|
||||
}
|
||||
}
|
||||
resolve (fillerIds);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getAllFillers() {
|
||||
let ids = await this.getAllFillerIds();
|
||||
return await Promise.all( ids.map( async (c) => this.getFiller(c) ) );
|
||||
}
|
||||
|
||||
async getAllFillersInfo() {
|
||||
//returns just name and id
|
||||
let fillers = await this.getAllFillers();
|
||||
return fillers.map( (f) => {
|
||||
return {
|
||||
'id' : f.id,
|
||||
'name': f.name,
|
||||
'count': f.content.length,
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
async getFillersFromChannel(channel) {
|
||||
let f = [];
|
||||
if (typeof(channel.fillerCollections) !== 'undefined') {
|
||||
f = channel.fillerContent;
|
||||
}
|
||||
let loadChannelFiller = async(fillerEntry) => {
|
||||
let content = [];
|
||||
try {
|
||||
let filler = await this.getFiller(fillerEntry.id);
|
||||
content = filler.content;
|
||||
} catch(e) {
|
||||
console.error(`Channel #${channel.number} - ${channel.name} references an unattainable filler id: ${fillerEntry.id}`);
|
||||
}
|
||||
return {
|
||||
id: fillerEntry.id,
|
||||
content: content,
|
||||
weight: fillerEntry.weight,
|
||||
cooldown: fillerEntry.cooldown,
|
||||
}
|
||||
};
|
||||
return await Promise.all(
|
||||
channel.fillerCollections.map(loadChannelFiller)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
function fixup(json) {
|
||||
if (typeof(json.content) === 'undefined') {
|
||||
json.content = [];
|
||||
}
|
||||
if (typeof(json.name) === 'undefined') {
|
||||
json.name = "Unnamed Filler";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FillerDB;
|
||||
248
src/dao/plex-server-db.js
Normal file
@ -0,0 +1,248 @@
|
||||
|
||||
//hmnn this is more of a "PlexServerService"...
|
||||
const ICON_REGEX = /https?:\/\/.*(\/library\/metadata\/\d+\/thumb\/\d+).X-Plex-Token=.*/;
|
||||
|
||||
const ICON_FIELDS = ["icon", "showIcon", "seasonIcon", "episodeIcon"];
|
||||
|
||||
// DB is a misnomer here, this is closer to a service
|
||||
class PlexServerDB
|
||||
{
|
||||
constructor(channelService, fillerDB, showDB, db) {
|
||||
this.channelService = channelService;
|
||||
this.db = db;
|
||||
|
||||
this.fillerDB = fillerDB;
|
||||
this.showDB = showDB;
|
||||
}
|
||||
|
||||
async fixupAllChannels(name, newServer) {
|
||||
let channelNumbers = await this.channelService.getAllChannelNumbers();
|
||||
let report = await Promise.all( channelNumbers.map( async (i) => {
|
||||
let channel = await this.channelService.getChannel(i);
|
||||
let channelReport = {
|
||||
channelNumber : channel.number,
|
||||
channelName : channel.name,
|
||||
destroyedPrograms: 0,
|
||||
modifiedPrograms: 0,
|
||||
};
|
||||
this.fixupProgramArray(channel.programs, name,newServer, channelReport);
|
||||
//if fallback became offline, remove it
|
||||
if (
|
||||
(typeof(channel.fallback) !=='undefined')
|
||||
&& (channel.fallback.length > 0)
|
||||
&& (channel.fallback[0].isOffline)
|
||||
) {
|
||||
channel.fallback = [];
|
||||
if (channel.offlineMode != "pic") {
|
||||
channel.offlineMode = "pic";
|
||||
channel.offlinePicture = `http://localhost:${process.env.PORT}/images/generic-offline-screen.png`;
|
||||
}
|
||||
}
|
||||
this.fixupProgramArray(channel.fallback, name,newServer, channelReport);
|
||||
await this.channelService.saveChannel(i, channel);
|
||||
return channelReport;
|
||||
}) );
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
async fixupAllFillers(name, newServer) {
|
||||
let fillers = await this.fillerDB.getAllFillers();
|
||||
let report = await Promise.all( fillers.map( async (filler) => {
|
||||
let fillerReport = {
|
||||
channelNumber : "--",
|
||||
channelName : filler.name + " (filler)",
|
||||
destroyedPrograms: 0,
|
||||
modifiedPrograms: 0,
|
||||
};
|
||||
this.fixupProgramArray( filler.content, name,newServer, fillerReport );
|
||||
filler.content = this.removeOffline(filler.content);
|
||||
|
||||
await this.fillerDB.saveFiller( filler.id, filler );
|
||||
|
||||
return fillerReport;
|
||||
} ) );
|
||||
return report;
|
||||
|
||||
}
|
||||
|
||||
async fixupAllShows(name, newServer) {
|
||||
let shows = await this.showDB.getAllShows();
|
||||
let report = await Promise.all( shows.map( async (show) => {
|
||||
let showReport = {
|
||||
channelNumber : "--",
|
||||
channelName : show.name + " (custom show)",
|
||||
destroyedPrograms: 0,
|
||||
modifiedPrograms: 0,
|
||||
};
|
||||
this.fixupProgramArray( show.content, name,newServer, showReport );
|
||||
show.content = this.removeOffline(show.content);
|
||||
|
||||
await this.showDB.saveShow( show.id, show );
|
||||
|
||||
return showReport;
|
||||
} ) );
|
||||
return report;
|
||||
|
||||
}
|
||||
|
||||
|
||||
removeOffline( progs ) {
|
||||
if (typeof(progs) === 'undefined') {
|
||||
return progs;
|
||||
}
|
||||
return progs.filter(
|
||||
(p) => {
|
||||
return (true !== p.isOffline);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async fixupEveryProgramHolders(serverName, newServer) {
|
||||
let reports = await Promise.all( [
|
||||
this.fixupAllChannels( serverName, newServer ),
|
||||
this.fixupAllFillers(serverName, newServer),
|
||||
this.fixupAllShows(serverName, newServer),
|
||||
] );
|
||||
let report = [];
|
||||
reports.forEach(
|
||||
(r) => r.forEach( (r2) => {
|
||||
report.push(r2)
|
||||
} )
|
||||
);
|
||||
return report;
|
||||
}
|
||||
|
||||
async deleteServer(name) {
|
||||
let report = await this.fixupEveryProgramHolders(name, null);
|
||||
this.db['plex-servers'].remove( { name: name } );
|
||||
return report;
|
||||
}
|
||||
|
||||
doesNameExist(name) {
|
||||
return this.db['plex-servers'].find( { name: name} ).length > 0;
|
||||
}
|
||||
|
||||
async updateServer(server) {
|
||||
let name = server.name;
|
||||
if (typeof(name) === 'undefined') {
|
||||
throw Error("Missing server name from request");
|
||||
}
|
||||
let s = this.db['plex-servers'].find( { name: name} );
|
||||
if (s.length != 1) {
|
||||
throw Error("Server doesn't exist.");
|
||||
}
|
||||
s = s[0];
|
||||
let arGuide = server.arGuide;
|
||||
if (typeof(arGuide) === 'undefined') {
|
||||
arGuide = false;
|
||||
}
|
||||
let arChannels = server.arChannels;
|
||||
if (typeof(arChannels) === 'undefined') {
|
||||
arChannels = false;
|
||||
}
|
||||
let newServer = {
|
||||
name: s.name,
|
||||
uri: server.uri,
|
||||
accessToken: server.accessToken,
|
||||
arGuide: arGuide,
|
||||
arChannels: arChannels,
|
||||
index: s.index,
|
||||
}
|
||||
this.normalizeServer(newServer);
|
||||
|
||||
let report = await this.fixupEveryProgramHolders(name, newServer);
|
||||
|
||||
this.db['plex-servers'].update(
|
||||
{ _id: s._id },
|
||||
newServer
|
||||
);
|
||||
return report;
|
||||
|
||||
|
||||
}
|
||||
|
||||
async addServer(server) {
|
||||
let name = server.name;
|
||||
if (typeof(name) === 'undefined') {
|
||||
name = "plex";
|
||||
}
|
||||
let i = 2;
|
||||
let prefix = name;
|
||||
let resultName = name;
|
||||
while (this.doesNameExist(resultName)) {
|
||||
resultName = `${prefix}${i}` ;
|
||||
i += 1;
|
||||
}
|
||||
name = resultName;
|
||||
let arGuide = server.arGuide;
|
||||
if (typeof(arGuide) === 'undefined') {
|
||||
arGuide = false;
|
||||
}
|
||||
let arChannels = server.arGuide;
|
||||
if (typeof(arChannels) === 'undefined') {
|
||||
arChannels = false;
|
||||
}
|
||||
let index = this.db['plex-servers'].find({}).length;
|
||||
|
||||
let newServer = {
|
||||
name: name,
|
||||
uri: server.uri,
|
||||
accessToken: server.accessToken,
|
||||
arGuide: arGuide,
|
||||
arChannels: arChannels,
|
||||
index: index,
|
||||
};
|
||||
this.normalizeServer(newServer);
|
||||
this.db['plex-servers'].save(newServer);
|
||||
}
|
||||
|
||||
fixupProgramArray(arr, serverName,newServer, channelReport) {
|
||||
if (typeof(arr) !== 'undefined') {
|
||||
for(let i = 0; i < arr.length; i++) {
|
||||
arr[i] = this.fixupProgram( arr[i], serverName,newServer, channelReport );
|
||||
}
|
||||
}
|
||||
}
|
||||
fixupProgram(program, serverName,newServer, channelReport) {
|
||||
if ( (program.serverKey === serverName) && (newServer == null) ) {
|
||||
channelReport.destroyedPrograms += 1;
|
||||
return {
|
||||
isOffline: true,
|
||||
duration: program.duration,
|
||||
}
|
||||
} else if (program.serverKey === serverName) {
|
||||
let modified = false;
|
||||
ICON_FIELDS.forEach( (field) => {
|
||||
if (
|
||||
(typeof(program[field] ) === 'string')
|
||||
&&
|
||||
program[field].includes("/library/metadata")
|
||||
&&
|
||||
program[field].includes("X-Plex-Token")
|
||||
) {
|
||||
let m = program[field].match(ICON_REGEX);
|
||||
if (m.length == 2) {
|
||||
let lib = m[1];
|
||||
let newUri = `${newServer.uri}${lib}?X-Plex-Token=${newServer.accessToken}`
|
||||
program[field] = newUri;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
} );
|
||||
if (modified) {
|
||||
channelReport.modifiedPrograms += 1;
|
||||
}
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
normalizeServer(server) {
|
||||
while (server.uri.endsWith("/")) {
|
||||
server.uri = server.uri.slice(0,-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlexServerDB
|
||||
90
src/dao/program-play-time-db.js
Normal file
@ -0,0 +1,90 @@
|
||||
const path = require('path');
|
||||
var fs = require('fs');
|
||||
|
||||
class ProgramPlayTimeDB {
|
||||
|
||||
constructor(dir) {
|
||||
this.dir = dir;
|
||||
this.programPlayTimeCache = {};
|
||||
}
|
||||
|
||||
|
||||
async load() {
|
||||
try {
|
||||
if (! (await fs.promises.stat(this.dir)).isDirectory()) {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
let files = await fs.promises.readdir(this.dir);
|
||||
|
||||
let processSubFileName = async (fileName, subDir, subFileName) => {
|
||||
try {
|
||||
if (subFileName.endsWith(".json")) {
|
||||
let programKey64 = subFileName.substring(
|
||||
0,
|
||||
subFileName.length - 4
|
||||
);
|
||||
let programKey = Buffer.from(programKey64, 'base64')
|
||||
.toString('utf-8');
|
||||
|
||||
|
||||
let filePath = path.join(subDir, subFileName);
|
||||
let fileContent = await fs.promises.readFile(
|
||||
filePath, 'utf-8');
|
||||
let jsonData = JSON.parse(fileContent);
|
||||
let key = getKey(fileName, programKey);
|
||||
this.programPlayTimeCache[ key ] = jsonData["t"]
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`When processing ${subDir}/${subFileName}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
let processFileName = async(fileName) => {
|
||||
|
||||
try {
|
||||
const subDir = path.join(this.dir, fileName);
|
||||
let subFiles = await fs.promises.readdir( subDir );
|
||||
|
||||
await Promise.all( subFiles.map( async subFileName => {
|
||||
return processSubFileName(fileName, subDir, subFileName);
|
||||
}) );
|
||||
} catch (err) {
|
||||
console.log(`When processing ${subDir}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all( files.map(processFileName) );
|
||||
}
|
||||
|
||||
getProgramLastPlayTime(channelId, programKey) {
|
||||
let v = this.programPlayTimeCache[ getKey(channelId, programKey) ];
|
||||
if (typeof(v) === 'undefined') {
|
||||
v = 0;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
async update(channelId, programKey, t) {
|
||||
|
||||
let key = getKey(channelId, programKey);
|
||||
this.programPlayTimeCache[ key ] = t;
|
||||
|
||||
const channelDir = path.join(this.dir, `${channelId}`);
|
||||
await fs.promises.mkdir( channelDir, { recursive: true } );
|
||||
let key64 = Buffer.from(programKey, 'utf-8').toString('base64');
|
||||
let filepath = path.join(channelDir, `${key64}.json`);
|
||||
let data = {t:t};
|
||||
await fs.promises.writeFile(filepath, JSON.stringify(data), 'utf-8');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getKey(channelId, programKey) {
|
||||
return channelId + "|" + programKey;
|
||||
}
|
||||
|
||||
|
||||
module.exports = ProgramPlayTimeDB;
|
||||
903
src/database-migration.js
Normal file
@ -1,73 +0,0 @@
|
||||
function ffmpeg() {
|
||||
return {
|
||||
//a record of the config version will help migrating between versions
|
||||
// in the future. Always increase the version when new ffmpeg configs
|
||||
// are added.
|
||||
//
|
||||
// configVersion 3: First versioned config.
|
||||
//
|
||||
configVersion: 4,
|
||||
ffmpegPath: "/usr/bin/ffmpeg",
|
||||
threads: 4,
|
||||
concatMuxDelay: "0",
|
||||
logFfmpeg: false,
|
||||
enableFFMPEGTranscoding: false,
|
||||
audioVolumePercent: 100,
|
||||
videoEncoder: "mpeg2video",
|
||||
audioEncoder: "ac3",
|
||||
targetResolution: "1920x1080",
|
||||
videoBitrate: 10000,
|
||||
videoBufSize: 2000,
|
||||
audioBitrate: 192,
|
||||
audioBufSize: 50,
|
||||
audioSampleRate: 48,
|
||||
audioChannels: 2,
|
||||
errorScreen: "pic",
|
||||
errorAudio: "silent",
|
||||
normalizeVideoCodec: false,
|
||||
normalizeAudioCodec: false,
|
||||
normalizeResolution: false,
|
||||
normalizeAudio: false,
|
||||
}
|
||||
}
|
||||
|
||||
function repairFFmpeg(existingConfigs) {
|
||||
var hasBeenRepaired = false;
|
||||
var currentConfig = {};
|
||||
var _id = null;
|
||||
if (existingConfigs.length === 0) {
|
||||
currentConfig = {};
|
||||
} else {
|
||||
currentConfig = existingConfigs[0];
|
||||
_id = currentConfig._id;
|
||||
}
|
||||
if (
|
||||
(typeof(currentConfig.configVersion) === 'undefined')
|
||||
|| (currentConfig.configVersion < 3)
|
||||
) {
|
||||
hasBeenRepaired = true;
|
||||
currentConfig = ffmpeg();
|
||||
currentConfig._id = _id;
|
||||
}
|
||||
if (currentConfig.configVersion == 3) {
|
||||
//migrate from version 3 to 4
|
||||
hasBeenRepaired = true;
|
||||
//new settings:
|
||||
currentConfig.audioBitrate = 192;
|
||||
currentConfig.audioBufSize = 50;
|
||||
currentConfig.audioChannels = 2;
|
||||
currentConfig.audioSampleRate = 48;
|
||||
//this one has been renamed:
|
||||
currentConfig.normalizeAudio = currentConfig.alignAudio;
|
||||
currentConfig.configVersion = 4;
|
||||
}
|
||||
return {
|
||||
hasBeenRepaired: hasBeenRepaired,
|
||||
fixedConfig : currentConfig,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ffmpeg: ffmpeg,
|
||||
repairFFmpeg: repairFFmpeg,
|
||||
}
|
||||
31
src/ffmpeg-info.js
Normal file
@ -0,0 +1,31 @@
|
||||
const exec = require('child_process').exec;
|
||||
|
||||
class FFMPEGInfo {
|
||||
constructor(opts) {
|
||||
this.ffmpegPath = opts.ffmpegPath
|
||||
}
|
||||
async getVersion() {
|
||||
try {
|
||||
let s = await new Promise( (resolve, reject) => {
|
||||
exec( `"${this.ffmpegPath}" -version`, function(error, stdout, stderr){
|
||||
if (error !== null) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
});
|
||||
var m = s.match( /version\s+([^\s]+)\s+.*Copyright/ )
|
||||
if (m == null) {
|
||||
console.error("ffmpeg -version command output not in the expected format: " + s);
|
||||
return "Unknown";
|
||||
}
|
||||
return m[1];
|
||||
} catch (err) {
|
||||
console.error("Error getting ffmpeg version", err);
|
||||
return "Error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FFMPEGInfo
|
||||
474
src/ffmpeg.js
23
src/hdhr.js
@ -3,7 +3,7 @@ const SSDP = require('node-ssdp').Server
|
||||
|
||||
module.exports = hdhr
|
||||
|
||||
function hdhr(db) {
|
||||
function hdhr(db, channelDB) {
|
||||
|
||||
const server = new SSDP({
|
||||
location: {
|
||||
@ -43,14 +43,17 @@ function hdhr(db) {
|
||||
}
|
||||
res.send(JSON.stringify(data))
|
||||
})
|
||||
router.get('/lineup.json', (req, res) => {
|
||||
router.get('/lineup.json', async (req, res) => {
|
||||
res.header("Content-Type", "application/json")
|
||||
var lineup = []
|
||||
var channels = db['channels'].find()
|
||||
for (let i = 0, l = channels.length; i < l; i++)
|
||||
var channels = await channelDB.getAllChannels();
|
||||
for (let i = 0, l = channels.length; i < l; i++) {
|
||||
if (channels[i].stealth !== true) {
|
||||
lineup.push({ GuideNumber: channels[i].number.toString(), GuideName: channels[i].name, URL: `${req.protocol}://${req.get('host')}/video?channel=${channels[i].number}` })
|
||||
}
|
||||
}
|
||||
if (lineup.length === 0)
|
||||
lineup.push({ GuideNumber: '1', GuideName: 'PseudoTV', URL: `${req.protocol}://${req.get('host')}/setup` })
|
||||
lineup.push({ GuideNumber: '1', GuideName: 'dizqueTV', URL: `${req.protocol}://${req.get('host')}/setup` })
|
||||
res.send(JSON.stringify(lineup))
|
||||
})
|
||||
|
||||
@ -60,14 +63,14 @@ function hdhr(db) {
|
||||
function getDevice(db, host) {
|
||||
let hdhrSettings = db['hdhr-settings'].find()[0]
|
||||
var device = {
|
||||
FriendlyName: "PseudoTV",
|
||||
Manufacturer: "PseudoTV - Silicondust",
|
||||
ManufacturerURL: "https://gitlab.org/DEFENDORe/pseudotv-plex",
|
||||
FriendlyName: "dizqueTV",
|
||||
Manufacturer: "dizqueTV - Silicondust",
|
||||
ManufacturerURL: "https://github.com/vexorian/dizquetv",
|
||||
ModelNumber: "HDTC-2US",
|
||||
FirmwareName: "hdhomeruntc_atsc",
|
||||
TunerCount: hdhrSettings.tunerCount,
|
||||
FirmwareVersion: "20170930",
|
||||
DeviceID: 'PseudoTV',
|
||||
DeviceID: 'dizqueTV',
|
||||
DeviceAuth: "",
|
||||
BaseURL: `${host}`,
|
||||
LineupURL: `${host}/lineup.json`
|
||||
@ -82,7 +85,7 @@ function getDevice(db, host) {
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>PseudoTV</friendlyName>
|
||||
<friendlyName>dizqueTV</friendlyName>
|
||||
<manufacturer>Silicondust</manufacturer>
|
||||
<modelName>HDTC-2US</modelName>
|
||||
<modelNumber>HDTC-2US</modelNumber>
|
||||
|
||||
@ -1,17 +1,47 @@
|
||||
module.exports = {
|
||||
getCurrentProgramAndTimeElapsed: getCurrentProgramAndTimeElapsed,
|
||||
createLineup: createLineup,
|
||||
isChannelIconEnabled: isChannelIconEnabled,
|
||||
getWatermark: getWatermark,
|
||||
generateChannelContext: generateChannelContext,
|
||||
}
|
||||
|
||||
let channelCache = require('./channel-cache');
|
||||
const INFINITE_TIME = new Date().getTime() + 10*365*24*60*60*1000; //10 years from the initialization of the server. I dunno, I just wanted it to be a high time without it stopping being human readable if converted to date.
|
||||
const SLACK = require('./constants').SLACK;
|
||||
const randomJS = require("random-js");
|
||||
const quickselect = require("quickselect");
|
||||
const Random = randomJS.Random;
|
||||
const random = new Random( randomJS.MersenneTwister19937.autoSeed() );
|
||||
|
||||
const CHANNEL_CONTEXT_KEYS = [
|
||||
"disableFillerOverlay",
|
||||
"watermark",
|
||||
"icon",
|
||||
"offlinePicture",
|
||||
"offlineSoundtrack",
|
||||
"name",
|
||||
"transcoding",
|
||||
"number",
|
||||
];
|
||||
|
||||
module.exports.random = random;
|
||||
|
||||
function getCurrentProgramAndTimeElapsed(date, channel) {
|
||||
let channelStartTime = new Date(channel.startTime)
|
||||
if (channelStartTime > date)
|
||||
throw new Error("startTime cannot be set in the future. something fucked up..")
|
||||
let timeElapsed = (date.valueOf() - channelStartTime.valueOf()) % channel.duration
|
||||
let channelStartTime = (new Date(channel.startTime)).getTime();
|
||||
if (channelStartTime > date) {
|
||||
let t0 = date;
|
||||
let t1 = channelStartTime;
|
||||
console.log("Channel start time is above the given date. Flex time is picked till that.");
|
||||
return {
|
||||
program: {
|
||||
isOffline: true,
|
||||
duration : t1 - t0,
|
||||
},
|
||||
timeElapsed: 0,
|
||||
programIndex: -1,
|
||||
}
|
||||
}
|
||||
let timeElapsed = (date - channelStartTime) % channel.duration
|
||||
let currentProgramIndex = -1
|
||||
for (let y = 0, l2 = channel.programs.length; y < l2; y++) {
|
||||
let program = channel.programs[y]
|
||||
@ -33,31 +63,47 @@ function getCurrentProgramAndTimeElapsed(date, channel) {
|
||||
return { program: channel.programs[currentProgramIndex], timeElapsed: timeElapsed, programIndex: currentProgramIndex }
|
||||
}
|
||||
|
||||
function createLineup(obj, channel, isFirst) {
|
||||
function createLineup(programPlayTime, obj, channel, fillers, isFirst) {
|
||||
let timeElapsed = obj.timeElapsed
|
||||
// Start time of a file is never consistent unless 0. Run time of an episode can vary.
|
||||
// When within 30 seconds of start time, just make the time 0 to smooth things out
|
||||
// Helps prevents loosing first few seconds of an episode upon lineup change
|
||||
let activeProgram = obj.program
|
||||
let beginningOffset = 0;
|
||||
|
||||
let lineup = []
|
||||
|
||||
if ( typeof(activeProgram.err) !== 'undefined') {
|
||||
let remaining = activeProgram.duration - timeElapsed;
|
||||
lineup.push( {
|
||||
type: 'offline',
|
||||
title: 'Error',
|
||||
err: activeProgram.err,
|
||||
streamDuration: remaining,
|
||||
duration: remaining,
|
||||
start: 0,
|
||||
beginningOffset: beginningOffset,
|
||||
})
|
||||
return lineup;
|
||||
}
|
||||
|
||||
|
||||
if (activeProgram.isOffline === true) {
|
||||
//offline case
|
||||
let remaining = activeProgram.actualDuration - timeElapsed;
|
||||
let remaining = activeProgram.duration - timeElapsed;
|
||||
//look for a random filler to play
|
||||
let filler = null;
|
||||
let special = null;
|
||||
if (typeof(channel.fillerContent) !== 'undefined') {
|
||||
|
||||
if ( (channel.offlineMode === 'clip') && (channel.fallback.length != 0) ) {
|
||||
special = JSON.parse(JSON.stringify(channel.fallback[0]));
|
||||
}
|
||||
let randomResult = pickRandomWithMaxDuration(channel, channel.fillerContent, remaining + (isFirst? (24*60*60*1000) : 0) );
|
||||
let randomResult = pickRandomWithMaxDuration(programPlayTime, channel, fillers, remaining + (isFirst? (7*24*60*60*1000) : 0) );
|
||||
filler = randomResult.filler;
|
||||
if (filler == null && (typeof(randomResult.minimumWait) !== undefined) && (remaining > randomResult.minimumWait) ) {
|
||||
remaining = randomResult.minimumWait;
|
||||
}
|
||||
}
|
||||
|
||||
let isSpecial = false;
|
||||
if (filler == null) {
|
||||
filler = special;
|
||||
@ -66,17 +112,17 @@ function createLineup(obj, channel, isFirst) {
|
||||
if (filler != null) {
|
||||
let fillerstart = 0;
|
||||
if (isSpecial) {
|
||||
if (filler.actualDuration > remaining) {
|
||||
fillerstart = filler.actualDuration - remaining;
|
||||
if (filler.duration > remaining) {
|
||||
fillerstart = filler.duration - remaining;
|
||||
} else {
|
||||
ffillerstart = 0;
|
||||
}
|
||||
} else if(isFirst) {
|
||||
fillerstart = Math.max(0, filler.actualDuration - remaining);
|
||||
fillerstart = Math.max(0, filler.duration - remaining);
|
||||
//it's boring and odd to tune into a channel and it's always
|
||||
//the start of a commercial.
|
||||
let more = Math.max(0, filler.actualDuration - fillerstart - 15000 - SLACK);
|
||||
fillerstart += Math.floor(more * Math.random() );
|
||||
let more = Math.max(0, filler.duration - fillerstart - 15000 - SLACK);
|
||||
fillerstart += random.integer(0, more);
|
||||
}
|
||||
lineup.push({ // just add the video, starting at 0, playing the entire duration
|
||||
type: 'commercial',
|
||||
@ -86,9 +132,11 @@ function createLineup(obj, channel, isFirst) {
|
||||
file: filler.file,
|
||||
ratingKey: filler.ratingKey,
|
||||
start: fillerstart,
|
||||
streamDuration: Math.max(1, Math.min(filler.actualDuration - fillerstart, remaining) ),
|
||||
duration: filler.actualDuration,
|
||||
server: filler.server
|
||||
streamDuration: Math.max(1, Math.min(filler.duration - fillerstart, remaining) ),
|
||||
duration: filler.duration,
|
||||
fillerId: filler.fillerId,
|
||||
beginningOffset: beginningOffset,
|
||||
serverKey: filler.serverKey
|
||||
});
|
||||
return lineup;
|
||||
}
|
||||
@ -101,146 +149,137 @@ function createLineup(obj, channel, isFirst) {
|
||||
type: 'offline',
|
||||
title: 'Channel Offline',
|
||||
streamDuration: remaining,
|
||||
beginningOffset: beginningOffset,
|
||||
duration: remaining,
|
||||
start: 0
|
||||
})
|
||||
return lineup;
|
||||
}
|
||||
let originalTimeElapsed = timeElapsed;
|
||||
if (timeElapsed < 30000) {
|
||||
timeElapsed = 0
|
||||
}
|
||||
beginningOffset = Math.max(0, originalTimeElapsed - timeElapsed);
|
||||
|
||||
let programStartTimes = [0, activeProgram.actualDuration * .25, activeProgram.actualDuration * .50, activeProgram.actualDuration * .75, activeProgram.actualDuration]
|
||||
let commercials = [[], [], [], [], []]
|
||||
for (let i = 0, l = activeProgram.commercials.length; i < l; i++) // Sort the commercials into their own commerical "slot" array
|
||||
commercials[activeProgram.commercials[i].commercialPosition].push(activeProgram.commercials[i])
|
||||
|
||||
let foundFirstVideo = false
|
||||
let progTimeElapsed = 0
|
||||
for (let i = 0, l = commercials.length; i < l; i++) { // Foreach commercial slot
|
||||
for (let y = 0, l2 = commercials[i].length; y < l2; y++) { // Foreach commercial in that slot
|
||||
if (!foundFirstVideo && timeElapsed - commercials[i][y].duration < 0) { // If havent already found the starting video AND the this is a the starting video
|
||||
foundFirstVideo = true // We found the fucker
|
||||
lineup.push({
|
||||
type: 'commercial',
|
||||
title: commercials[i][y].title,
|
||||
key: commercials[i][y].key,
|
||||
plexFile: commercials[i][y].plexFile,
|
||||
file: commercials[i][y].file,
|
||||
ratingKey: commercials[i][y].ratingKey,
|
||||
start: timeElapsed, // start time will be the time elapsed, cause this is the first video
|
||||
streamDuration: commercials[i][y].duration - timeElapsed, // stream duration set accordingly
|
||||
duration: commercials[i][y].duration,
|
||||
server: commercials[i][y].server
|
||||
})
|
||||
} else if (foundFirstVideo) { // Otherwise, if weve already found the starting video
|
||||
lineup.push({ // just add the video, starting at 0, playing the entire duration
|
||||
type: 'commercial',
|
||||
title: commercials[i][y].title,
|
||||
key: commercials[i][y].key,
|
||||
plexFile: commercials[i][y].plexFile,
|
||||
file: commercials[i][y].file,
|
||||
ratingKey: commercials[i][y].ratingKey,
|
||||
start: 0,
|
||||
streamDuration: commercials[i][y].actualDuration,
|
||||
duration: commercials[i][y].actualDuration,
|
||||
server: commercials[i][y].server
|
||||
})
|
||||
} else { // Otherwise, this bitch has already been played.. Reduce the time elapsed by its duration
|
||||
timeElapsed -= commercials[i][y].actualDuration
|
||||
}
|
||||
}
|
||||
if (i < l - 1) { // The last commercial slot is END, so dont write a program..
|
||||
if (!foundFirstVideo && timeElapsed - (programStartTimes[i + 1] - programStartTimes[i]) < 0) { // same shit as above..
|
||||
foundFirstVideo = true
|
||||
lineup.push({
|
||||
type: 'program',
|
||||
title: activeProgram.title,
|
||||
key: activeProgram.key,
|
||||
plexFile: activeProgram.plexFile,
|
||||
file: activeProgram.file,
|
||||
ratingKey: activeProgram.ratingKey,
|
||||
start: progTimeElapsed + timeElapsed, // add the duration of already played program chunks to the timeElapsed
|
||||
streamDuration: (programStartTimes[i + 1] - programStartTimes[i]) - timeElapsed,
|
||||
duration: activeProgram.actualDuration,
|
||||
server: activeProgram.server
|
||||
})
|
||||
} else if (foundFirstVideo) {
|
||||
if (lineup[lineup.length - 1].type === 'program') { // merge consecutive programs..
|
||||
lineup[lineup.length - 1].streamDuration += (programStartTimes[i + 1] - programStartTimes[i])
|
||||
} else {
|
||||
lineup.push({
|
||||
return [ {
|
||||
type: 'program',
|
||||
title: activeProgram.title,
|
||||
key: activeProgram.key,
|
||||
plexFile: activeProgram.plexFile,
|
||||
file: activeProgram.file,
|
||||
ratingKey: activeProgram.ratingKey,
|
||||
start: programStartTimes[i],
|
||||
streamDuration: (programStartTimes[i + 1] - programStartTimes[i]),
|
||||
duration: activeProgram.actualDuration,
|
||||
server: activeProgram.server
|
||||
})
|
||||
}
|
||||
} else {
|
||||
timeElapsed -= (programStartTimes[i + 1] - programStartTimes[i])
|
||||
progTimeElapsed += (programStartTimes[i + 1] - programStartTimes[i]) // add the duration of already played program chunks together
|
||||
}
|
||||
}
|
||||
}
|
||||
return lineup
|
||||
start: timeElapsed,
|
||||
streamDuration: activeProgram.duration - timeElapsed,
|
||||
beginningOffset: beginningOffset,
|
||||
duration: activeProgram.duration,
|
||||
serverKey: activeProgram.serverKey
|
||||
} ];
|
||||
}
|
||||
|
||||
function pickRandomWithMaxDuration(channel, list, maxDuration) {
|
||||
function weighedPick(a, total) {
|
||||
return random.bool(a, total);
|
||||
}
|
||||
|
||||
function pickRandomWithMaxDuration(programPlayTime, channel, fillers, maxDuration) {
|
||||
let list = [];
|
||||
for (let i = 0; i < fillers.length; i++) {
|
||||
list = list.concat(fillers[i].content);
|
||||
}
|
||||
let pick1 = null;
|
||||
let pick2 = null;
|
||||
let n = 0;
|
||||
let m = 0;
|
||||
|
||||
let t0 = (new Date()).getTime();
|
||||
let minimumWait = 1000000000;
|
||||
const D = 24*60*60*1000;
|
||||
const D = 7*24*60*60*1000;
|
||||
const E = 5*60*60*1000;
|
||||
if (typeof(channel.fillerRepeatCooldown) === 'undefined') {
|
||||
channel.fillerRepeatCooldown = 30*60*1000;
|
||||
}
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
let listM = 0;
|
||||
let fillerId = undefined;
|
||||
|
||||
for (let medianCheck = 1; medianCheck >= 0; medianCheck--) {
|
||||
for (let j = 0; j < fillers.length; j++) {
|
||||
list = fillers[j].content;
|
||||
let pickedList = false;
|
||||
let n = 0;
|
||||
|
||||
let maximumPlayTimeAllowed = INFINITE_TIME;
|
||||
if (medianCheck==1) {
|
||||
//calculate the median
|
||||
let median = getFillerMedian(programPlayTime, channel, fillers[j]);
|
||||
if (median > 0) {
|
||||
maximumPlayTimeAllowed = median - 1;
|
||||
// allow any clip with a play time that's less than the median.
|
||||
} else {
|
||||
// initially all times are 0, so if the median is 0, all of those
|
||||
// are allowed.
|
||||
maximumPlayTimeAllowed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
let clip = list[i];
|
||||
// a few extra milliseconds won't hurt anyone, would it? dun dun dun
|
||||
if (clip.actualDuration <= maxDuration + SLACK ) {
|
||||
let t1 = channelCache.getProgramLastPlayTime( channel.number, clip );
|
||||
if (clip.duration <= maxDuration + SLACK ) {
|
||||
let t1 = channelCache.getProgramLastPlayTime(programPlayTime, channel.number, clip );
|
||||
if (t1 > maximumPlayTimeAllowed) {
|
||||
continue;
|
||||
}
|
||||
let timeSince = ( (t1 == 0) ? D : (t0 - t1) );
|
||||
|
||||
|
||||
if (timeSince < channel.fillerRepeatCooldown - SLACK) {
|
||||
let w = channel.fillerRepeatCooldown - timeSince;
|
||||
if (clip.actualDuration + w <= maxDuration + SLACK) {
|
||||
if (clip.duration + w <= maxDuration + SLACK) {
|
||||
minimumWait = Math.min(minimumWait, w);
|
||||
}
|
||||
timeSince = 0;
|
||||
//30 minutes is too little, don't repeat it at all
|
||||
}
|
||||
if (timeSince >= D) {
|
||||
n += 1;
|
||||
if ( Math.floor(n*Math.random()) == 0) {
|
||||
pick1 = clip;
|
||||
}
|
||||
} else {
|
||||
let adjust = Math.floor(timeSince / (60*1000));
|
||||
if (adjust > 0) {
|
||||
adjust = adjust * adjust;
|
||||
//weighed
|
||||
m += adjust;
|
||||
if ( Math.floor(m*Math.random()) < adjust) {
|
||||
pick2 = clip;
|
||||
} else if (!pickedList) {
|
||||
let t1 = channelCache.getFillerLastPlayTime( channel.number, fillers[j].id );
|
||||
let timeSince = ( (t1 == 0) ? D : (t0 - t1) );
|
||||
if (timeSince + SLACK >= fillers[j].cooldown) {
|
||||
//should we pick this list?
|
||||
listM += fillers[j].weight;
|
||||
if ( weighedPick(fillers[j].weight, listM) ) {
|
||||
pickedList = true;
|
||||
fillerId = fillers[j].id;
|
||||
n = 0;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
let w = fillers[j].cooldown - timeSince;
|
||||
if (clip.duration + w <= maxDuration + SLACK) {
|
||||
minimumWait = Math.min(minimumWait, w);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeSince <= 0) {
|
||||
continue;
|
||||
}
|
||||
let s = norm_s( (timeSince >= E) ? E : timeSince );
|
||||
let d = norm_d( clip.duration);
|
||||
let w = s + d;
|
||||
n += w;
|
||||
if (weighedPick(w,n)) {
|
||||
pick1 = clip;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pick1 != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let pick = (pick1 == null) ? pick2: pick1;
|
||||
let pickTitle = "null";
|
||||
let pick = pick1;
|
||||
if (pick != null) {
|
||||
pickTitle = pick.title;
|
||||
pick = JSON.parse( JSON.stringify(pick) );
|
||||
pick.fillerId = fillerId;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
filler: pick,
|
||||
@ -248,19 +287,93 @@ function pickRandomWithMaxDuration(channel, list, maxDuration) {
|
||||
}
|
||||
}
|
||||
|
||||
function isChannelIconEnabled( ffmpegSettings, channel, type) {
|
||||
function norm_d(x) {
|
||||
x /= 60 * 1000;
|
||||
if (x >= 3.0) {
|
||||
x = 3.0 + Math.log(x);
|
||||
}
|
||||
let y = 10000 * ( Math.ceil(x * 1000) + 1 );
|
||||
return Math.ceil(y / 1000000) + 1;
|
||||
}
|
||||
|
||||
function norm_s(x) {
|
||||
let y = Math.ceil(x / 600) + 1;
|
||||
y = y*y;
|
||||
return Math.ceil(y / 1000000) + 1;
|
||||
}
|
||||
|
||||
|
||||
// any channel thing used here should be added to channel context
|
||||
function getWatermark( ffmpegSettings, channel, type) {
|
||||
if (! ffmpegSettings.enableFFMPEGTranscoding || ffmpegSettings.disableChannelOverlay ) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
let d = channel.disableFillerOverlay;
|
||||
if (typeof(d) === 'undefined') {
|
||||
d = true;
|
||||
}
|
||||
if ( (typeof type !== `undefined`) && (type == 'commercial') && d ) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
if (channel.icon === '' || !channel.overlayIcon) {
|
||||
return false;
|
||||
let e = false;
|
||||
let icon = undefined;
|
||||
let watermark = {};
|
||||
if (typeof(channel.watermark) !== 'undefined') {
|
||||
watermark = channel.watermark;
|
||||
e = (watermark.enabled === true);
|
||||
icon = watermark.url;
|
||||
}
|
||||
return true;
|
||||
if (! e) {
|
||||
return null;
|
||||
}
|
||||
if ( (typeof(icon) === 'undefined') || (icon === '') ) {
|
||||
icon = channel.icon;
|
||||
if ( (typeof(icon) === 'undefined') || (icon === '') ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
let result = {
|
||||
url: icon,
|
||||
width: watermark.width,
|
||||
verticalMargin: watermark.verticalMargin,
|
||||
horizontalMargin: watermark.horizontalMargin,
|
||||
duration: watermark.duration,
|
||||
position: watermark.position,
|
||||
fixedSize: (watermark.fixedSize === true),
|
||||
animated: (watermark.animated === true),
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
function getFillerMedian(programPlayTime, channel, filler) {
|
||||
|
||||
let times = [];
|
||||
list = filler.content;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
let clip = list[i];
|
||||
let t = channelCache.getProgramLastPlayTime(programPlayTime, channel.number, clip);
|
||||
times.push(t);
|
||||
}
|
||||
|
||||
if (times.length <= 1) {
|
||||
//if there are too few elements, the protection is not helpful.
|
||||
return INFINITE_TIME;
|
||||
}
|
||||
let m = Math.floor(times.length / 2);
|
||||
quickselect(times, m)
|
||||
return times[m];
|
||||
|
||||
}
|
||||
|
||||
function generateChannelContext(channel) {
|
||||
let channelContext = {};
|
||||
for (let i = 0; i < CHANNEL_CONTEXT_KEYS.length; i++) {
|
||||
let key = CHANNEL_CONTEXT_KEYS[i];
|
||||
|
||||
if (typeof(channel[key]) !== 'undefined') {
|
||||
channelContext[key] = JSON.parse( JSON.stringify(channel[key] ) );
|
||||
}
|
||||
}
|
||||
return channelContext;
|
||||
}
|
||||
|
||||
@ -13,36 +13,71 @@ class OfflinePlayer {
|
||||
constructor(error, context) {
|
||||
this.context = context;
|
||||
this.error = error;
|
||||
if (context.isLoading === true) {
|
||||
context.channel = JSON.parse( JSON.stringify(context.channel) );
|
||||
context.channel.offlinePicture = `http://localhost:${process.env.PORT}/images/loading-screen.png`;
|
||||
context.channel.offlineSoundtrack = undefined;
|
||||
}
|
||||
if (context.isInterlude === true) {
|
||||
context.channel = JSON.parse( JSON.stringify(context.channel) );
|
||||
context.channel.offlinePicture = `http://localhost:${process.env.PORT}/images/black.png`;
|
||||
context.channel.offlineSoundtrack = undefined;
|
||||
}
|
||||
this.ffmpeg = new FFMPEG(context.ffmpegSettings, context.channel);
|
||||
this.ffmpeg.setAudioOnly(this.context.audioOnly);
|
||||
}
|
||||
|
||||
cleanUp() {
|
||||
this.ffmpeg.kill();
|
||||
}
|
||||
|
||||
async play() {
|
||||
async play(outStream) {
|
||||
try {
|
||||
let emitter = new EventEmitter();
|
||||
let ffmpeg = this.ffmpeg;
|
||||
let lineupItem = this.context.lineupItem;
|
||||
let duration = lineupItem.streamDuration - lineupItem.start;
|
||||
let ff;
|
||||
if (this.error) {
|
||||
ffmpeg.spawnError(duration);
|
||||
ff = await ffmpeg.spawnError(duration);
|
||||
} else {
|
||||
ffmpeg.spawnOffline(duration);
|
||||
ff = await ffmpeg.spawnOffline(duration);
|
||||
}
|
||||
ff.pipe(outStream, {'end':false} );
|
||||
|
||||
ffmpeg.on('data', (data) => {
|
||||
emitter.emit('data', data);
|
||||
});
|
||||
ffmpeg.on('end', () => {
|
||||
emitter.emit('end');
|
||||
});
|
||||
ffmpeg.on('close', () => {
|
||||
emitter.emit('close');
|
||||
});
|
||||
ffmpeg.on('error', (err) => {
|
||||
emitter.emit('error', err);
|
||||
ffmpeg.on('error', async (err) => {
|
||||
//wish this code wasn't repeated.
|
||||
if (! this.error ) {
|
||||
console.log("Replacing failed stream with error stream");
|
||||
ff.unpipe(outStream);
|
||||
ffmpeg.removeAllListeners('data');
|
||||
ffmpeg.removeAllListeners('end');
|
||||
ffmpeg.removeAllListeners('error');
|
||||
ffmpeg.removeAllListeners('close');
|
||||
ffmpeg = new FFMPEG(this.context.ffmpegSettings, this.context.channel); // Set the transcoder options
|
||||
ffmpeg.setAudioOnly(this.context.audioOnly);
|
||||
ffmpeg.on('close', () => {
|
||||
emitter.emit('close');
|
||||
});
|
||||
ffmpeg.on('end', () => {
|
||||
emitter.emit('end');
|
||||
});
|
||||
ffmpeg.on('error', (err) => {
|
||||
emitter.emit('error', err );
|
||||
});
|
||||
|
||||
ff = await ffmpeg.spawnError('oops', 'oops', Math.min(duration, 60000) );
|
||||
ff.pipe(outStream);
|
||||
} else {
|
||||
emitter.emit('error', err);
|
||||
}
|
||||
|
||||
});
|
||||
return emitter;
|
||||
} catch(err) {
|
||||
|
||||
@ -9,6 +9,9 @@ const PlexTranscoder = require('./plexTranscoder')
|
||||
const EventEmitter = require('events');
|
||||
const helperFuncs = require('./helperFuncs')
|
||||
const FFMPEG = require('./ffmpeg')
|
||||
const constants = require('./constants');
|
||||
|
||||
let USED_CLIENTS = {};
|
||||
|
||||
class PlexPlayer {
|
||||
|
||||
@ -17,9 +20,17 @@ class PlexPlayer {
|
||||
this.ffmpeg = null;
|
||||
this.plexTranscoder = null;
|
||||
this.killed = false;
|
||||
let coreClientId = this.context.db['client-id'].find()[0].clientId;
|
||||
let i = 0;
|
||||
while ( USED_CLIENTS[coreClientId+"-"+i]===true) {
|
||||
i++;
|
||||
}
|
||||
this.clientId = coreClientId+"-"+i;
|
||||
USED_CLIENTS[this.clientId] = true;
|
||||
}
|
||||
|
||||
cleanUp() {
|
||||
USED_CLIENTS[this.clientId] = false;
|
||||
this.killed = true;
|
||||
if (this.plexTranscoder != null) {
|
||||
this.plexTranscoder.stopUpdatingPlex();
|
||||
@ -31,22 +42,33 @@ class PlexPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
async play() {
|
||||
async play(outStream) {
|
||||
let lineupItem = this.context.lineupItem;
|
||||
let ffmpegSettings = this.context.ffmpegSettings;
|
||||
let db = this.context.db;
|
||||
let channel = this.context.channel;
|
||||
let server = db['plex-servers'].find( { 'name': lineupItem.serverKey } );
|
||||
if (server.length == 0) {
|
||||
throw Error(`Unable to find server "${lineupItem.serverKey}" specified by program.`);
|
||||
}
|
||||
server = server[0];
|
||||
if (server.uri.endsWith("/")) {
|
||||
server.uri = server.uri.slice(0, server.uri.length - 1);
|
||||
}
|
||||
|
||||
try {
|
||||
let plexSettings = db['plex-settings'].find()[0];
|
||||
let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem);
|
||||
let plexTranscoder = new PlexTranscoder(this.clientId, server, plexSettings, channel, lineupItem);
|
||||
this.plexTranscoder = plexTranscoder;
|
||||
let enableChannelIcon = this.context.enableChannelIcon;
|
||||
let watermark = this.context.watermark;
|
||||
let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
|
||||
ffmpeg.setAudioOnly( this.context.audioOnly );
|
||||
this.ffmpeg = ffmpeg;
|
||||
let streamDuration;
|
||||
if (typeof(streamDuration)!=='undefined') {
|
||||
streamDuration = lineupItem.streamDuration / 1000;
|
||||
if (typeof(lineupItem.streamDuration)!=='undefined') {
|
||||
if (lineupItem.start + lineupItem.streamDuration + constants.SLACK < lineupItem.duration) {
|
||||
streamDuration = lineupItem.streamDuration / 1000;
|
||||
}
|
||||
}
|
||||
let deinterlace = ffmpegSettings.enableFFMPEGTranscoding; //for now it will always deinterlace when transcoding is enabled but this is sub-optimal
|
||||
|
||||
@ -63,30 +85,46 @@ class PlexPlayer {
|
||||
|
||||
let emitter = new EventEmitter();
|
||||
//setTimeout( () => {
|
||||
ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, enableChannelIcon, lineupItem.type); // Spawn the ffmpeg process
|
||||
let ff = await ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, watermark, lineupItem.type); // Spawn the ffmpeg process
|
||||
ff.pipe(outStream, {'end':false} );
|
||||
//}, 100);
|
||||
plexTranscoder.startUpdatingPlex();
|
||||
|
||||
ffmpeg.on('data', (data) => {
|
||||
emitter.emit('data', data);
|
||||
});
|
||||
|
||||
ffmpeg.on('end', () => {
|
||||
emitter.emit('end');
|
||||
});
|
||||
ffmpeg.on('close', () => {
|
||||
emitter.emit('close');
|
||||
});
|
||||
ffmpeg.on('error', (err) => {
|
||||
ffmpeg.on('error', async (err) => {
|
||||
console.log("Replacing failed stream with error stream");
|
||||
ff.unpipe(outStream);
|
||||
ffmpeg.removeAllListeners('data');
|
||||
ffmpeg.removeAllListeners('end');
|
||||
ffmpeg.removeAllListeners('error');
|
||||
ffmpeg.removeAllListeners('close');
|
||||
ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
|
||||
ffmpeg.setAudioOnly(this.context.audioOnly);
|
||||
ffmpeg.on('close', () => {
|
||||
emitter.emit('close');
|
||||
});
|
||||
ffmpeg.on('end', () => {
|
||||
emitter.emit('end');
|
||||
});
|
||||
ffmpeg.on('error', (err) => {
|
||||
emitter.emit('error', err );
|
||||
});
|
||||
|
||||
ff = await ffmpeg.spawnError('oops', 'oops', Math.min(streamStats.duration, 60000) );
|
||||
ff.pipe(outStream);
|
||||
|
||||
emitter.emit('error', err);
|
||||
});
|
||||
return emitter;
|
||||
|
||||
} catch(err) {
|
||||
if (err instanceof Error) {
|
||||
throw err;
|
||||
} else {
|
||||
return Error("Error when playing plex program: " + JSON.stringify(err) );
|
||||
}
|
||||
return Error("Error when playing plex program: " + JSON.stringify(err) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
98
src/plex.js
@ -2,17 +2,24 @@ const request = require('request')
|
||||
class Plex {
|
||||
constructor(opts) {
|
||||
this._accessToken = typeof opts.accessToken !== 'undefined' ? opts.accessToken : ''
|
||||
let uri = "http://127.0.0.1:32400";
|
||||
if ( (typeof opts.uri) !== 'undefined' ) {
|
||||
uri = opts.uri;
|
||||
if (uri.endsWith("/")) {
|
||||
uri = uri.slice(0, uri.length - 1);
|
||||
}
|
||||
}
|
||||
this._server = {
|
||||
uri: typeof opts.uri !== 'undefined' ? opts.uri : 'http://127.0.0.1:32400',
|
||||
uri: uri,
|
||||
host: typeof opts.host !== 'undefined' ? opts.host : '127.0.0.1',
|
||||
port: typeof opts.port !== 'undefined' ? opts.port : '32400',
|
||||
protocol: typeof opts.protocol !== 'undefined' ? opts.protocol : 'http'
|
||||
}
|
||||
this._headers = {
|
||||
'Accept': 'application/json',
|
||||
'X-Plex-Device': 'PseudoTV',
|
||||
'X-Plex-Device-Name': 'PseudoTV',
|
||||
'X-Plex-Product': 'PseudoTV',
|
||||
'X-Plex-Device': 'dizqueTV',
|
||||
'X-Plex-Device-Name': 'dizqueTV',
|
||||
'X-Plex-Product': 'dizqueTV',
|
||||
'X-Plex-Version': '0.1',
|
||||
'X-Plex-Client-Identifier': 'rg14zekk3pa5zp4safjwaa8z',
|
||||
'X-Plex-Platform': 'Chrome',
|
||||
@ -48,8 +55,23 @@ class Plex {
|
||||
})
|
||||
})
|
||||
}
|
||||
Get(path, optionalHeaders = {}) {
|
||||
var req = {
|
||||
|
||||
doRequest(req) {
|
||||
return new Promise( (resolve, reject) => {
|
||||
request( req, (err, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if ((res.statusCode < 200) || (res.statusCode >= 300) ) {
|
||||
reject( Error(`Request returned status code ${res.statusCode}`) );
|
||||
} else {
|
||||
resolve(res);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async Get(path, optionalHeaders = {}) {
|
||||
let req = {
|
||||
method: 'get',
|
||||
url: `${this.URL}${path}`,
|
||||
headers: this._headers,
|
||||
@ -57,19 +79,14 @@ class Plex {
|
||||
}
|
||||
Object.assign(req, optionalHeaders)
|
||||
req.headers['X-Plex-Token'] = this._accessToken
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this._accessToken === '')
|
||||
reject("No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.")
|
||||
else
|
||||
request(req, (err, res) => {
|
||||
if (err || res.statusCode !== 200)
|
||||
reject(`Plex 'Get' request failed. URL: ${this.URL}${path}`)
|
||||
else
|
||||
resolve(JSON.parse(res.body).MediaContainer)
|
||||
})
|
||||
})
|
||||
if (this._accessToken === '') {
|
||||
throw Error("No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.");
|
||||
} else {
|
||||
let res = await this.doRequest(req);
|
||||
return JSON.parse(res.body).MediaContainer;
|
||||
}
|
||||
}
|
||||
Put(path, query = {}, optionalHeaders = {}) {
|
||||
async Put(path, query = {}, optionalHeaders = {}) {
|
||||
var req = {
|
||||
method: 'put',
|
||||
url: `${this.URL}${path}`,
|
||||
@ -79,7 +96,7 @@ class Plex {
|
||||
}
|
||||
Object.assign(req, optionalHeaders)
|
||||
req.headers['X-Plex-Token'] = this._accessToken
|
||||
return new Promise((resolve, reject) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
if (this._accessToken === '')
|
||||
reject("No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.")
|
||||
else
|
||||
@ -113,31 +130,52 @@ class Plex {
|
||||
})
|
||||
})
|
||||
}
|
||||
async checkServerStatus() {
|
||||
try {
|
||||
await this.Get('/');
|
||||
return 1;
|
||||
} catch (err) {
|
||||
console.error("Error getting Plex server status", err);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
async GetDVRS() {
|
||||
var result = await this.Get('/livetv/dvrs')
|
||||
var dvrs = result.Dvr
|
||||
dvrs = typeof dvrs === 'undefined' ? [] : dvrs
|
||||
return dvrs
|
||||
try {
|
||||
var result = await this.Get('/livetv/dvrs')
|
||||
var dvrs = result.Dvr
|
||||
dvrs = typeof dvrs === 'undefined' ? [] : dvrs
|
||||
return dvrs
|
||||
} catch (err) {
|
||||
throw Error( "GET /livetv/drs failed: " + err.message);
|
||||
}
|
||||
}
|
||||
async RefreshGuide(_dvrs) {
|
||||
var dvrs = typeof _dvrs !== 'undefined' ? _dvrs : await this.GetDVRS()
|
||||
for (var i = 0; i < dvrs.length; i++)
|
||||
this.Post(`/livetv/dvrs/${dvrs[i].key}/reloadGuide`).then(() => { }, (err) => { console.log(err) })
|
||||
try {
|
||||
var dvrs = typeof _dvrs !== 'undefined' ? _dvrs : await this.GetDVRS()
|
||||
for (var i = 0; i < dvrs.length; i++) {
|
||||
await this.Post(`/livetv/dvrs/${dvrs[i].key}/reloadGuide`);
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error("Zort", err);
|
||||
}
|
||||
}
|
||||
async RefreshChannels(channels, _dvrs) {
|
||||
var dvrs = typeof _dvrs !== 'undefined' ? _dvrs : await this.GetDVRS()
|
||||
var _channels = []
|
||||
let qs = {}
|
||||
for (var i = 0; i < channels.length; i++)
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
_channels.push(channels[i].number)
|
||||
}
|
||||
qs.channelsEnabled = _channels.join(',')
|
||||
for (var i = 0; i < _channels.length; i++) {
|
||||
qs[`channelMapping[${_channels[i]}]`] = _channels[i]
|
||||
qs[`channelMappingByKey[${_channels[i]}]`] = _channels[i]
|
||||
}
|
||||
for (var i = 0; i < dvrs.length; i++)
|
||||
for (var y = 0; y < dvrs[i].Device.length; y++)
|
||||
this.Put(`/media/grabbers/devices/${dvrs[i].Device[y].key}/channelmap`, qs).then(() => { }, (err) => { console.log(err) })
|
||||
for (var i = 0; i < dvrs.length; i++) {
|
||||
for (var y = 0; y < dvrs[i].Device.length; y++) {
|
||||
await this.Put(`/media/grabbers/devices/${dvrs[i].Device[y].key}/channelmap`, qs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,24 +1,33 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
|
||||
class PlexTranscoder {
|
||||
constructor(settings, lineupItem) {
|
||||
constructor(clientId, server, settings, channel, lineupItem) {
|
||||
this.session = uuidv4()
|
||||
|
||||
this.device = "channel-" + channel.number;
|
||||
this.deviceName = this.device;
|
||||
this.clientIdentifier = clientId;
|
||||
this.product = "dizqueTV";
|
||||
|
||||
this.settings = settings
|
||||
|
||||
this.log("Plex transcoder initiated")
|
||||
this.log("Debug logging enabled")
|
||||
|
||||
this.key = lineupItem.key
|
||||
this.plexFile = `${lineupItem.server.uri}${lineupItem.plexFile}?X-Plex-Token=${lineupItem.server.accessToken}`
|
||||
this.file = lineupItem.file.replace(settings.pathReplace, settings.pathReplaceWith)
|
||||
this.transcodeUrlBase = `${lineupItem.server.uri}/video/:/transcode/universal/start.m3u8?`
|
||||
this.metadataPath = `${server.uri}${lineupItem.key}?X-Plex-Token=${server.accessToken}`
|
||||
this.plexFile = `${server.uri}${lineupItem.plexFile}?X-Plex-Token=${server.accessToken}`
|
||||
if (typeof(lineupItem.file)!=='undefined') {
|
||||
this.file = lineupItem.file.replace(settings.pathReplace, settings.pathReplaceWith)
|
||||
}
|
||||
this.transcodeUrlBase = `${server.uri}/video/:/transcode/universal/start.m3u8?`
|
||||
this.ratingKey = lineupItem.ratingKey
|
||||
this.currTimeMs = lineupItem.start
|
||||
this.currTimeS = this.currTimeMs / 1000
|
||||
this.duration = lineupItem.duration
|
||||
this.server = lineupItem.server
|
||||
this.server = server
|
||||
|
||||
this.transcodingArgs = undefined
|
||||
this.decisionJson = undefined
|
||||
@ -26,6 +35,11 @@ class PlexTranscoder {
|
||||
this.updateInterval = 30000
|
||||
this.updatingPlex = undefined
|
||||
this.playState = "stopped"
|
||||
this.mediaHasNoVideo = false;
|
||||
this.albumArt = {
|
||||
attempted : false,
|
||||
path: null,
|
||||
}
|
||||
}
|
||||
|
||||
async getStream(deinterlace) {
|
||||
@ -35,60 +49,83 @@ class PlexTranscoder {
|
||||
this.log(` deinterlace: ${deinterlace}`)
|
||||
this.log(` streamPath: ${this.settings.streamPath}`)
|
||||
|
||||
|
||||
this.setTranscodingArgs(stream.directPlay, true, false, false);
|
||||
await this.tryToDetectAudioOnly();
|
||||
|
||||
if (this.settings.streamPath === 'direct' || this.settings.forceDirectPlay) {
|
||||
if (this.settings.enableSubtitles) {
|
||||
this.log("Direct play is forced, so subtitles are forcibly disabled.");
|
||||
this.settings.enableSubtitles = false;
|
||||
}
|
||||
stream = {directPlay: true}
|
||||
} else {
|
||||
try {
|
||||
this.log("Setting transcoding parameters")
|
||||
this.setTranscodingArgs(stream.directPlay, true, deinterlace)
|
||||
this.setTranscodingArgs(stream.directPlay, true, deinterlace, this.mediaHasNoVideo)
|
||||
await this.getDecision(stream.directPlay);
|
||||
if (this.isDirectPlay()) {
|
||||
stream.directPlay = true;
|
||||
stream.streamUrl = this.plexFile;
|
||||
}
|
||||
} catch (err) {
|
||||
this.log("Error when getting decision. 1. Check Plex connection. 2. This might also be a sign that plex direct play and transcode settings are too strict and it can't find any allowed action for the selected video.")
|
||||
console.error("Error when getting decision. 1. Check Plex connection. 2. This might also be a sign that plex direct play and transcode settings are too strict and it can't find any allowed action for the selected video.", err)
|
||||
stream.directPlay = true;
|
||||
}
|
||||
}
|
||||
if (stream.directPlay) {
|
||||
if (stream.directPlay || this.isAV1() ) {
|
||||
if (! stream.directPlay) {
|
||||
this.log("Plex doesn't support av1, so we are forcing direct play, including for audio because otherwise plex breaks the stream.")
|
||||
}
|
||||
this.log("Direct play forced or native paths enabled")
|
||||
stream.directPlay = true
|
||||
this.setTranscodingArgs(stream.directPlay, true, false)
|
||||
this.setTranscodingArgs(stream.directPlay, true, false, this.mediaHasNoVideo )
|
||||
// Update transcode decision for session
|
||||
await this.getDecision(stream.directPlay);
|
||||
stream.streamUrl = (this.settings.streamPath === 'direct') ? this.file : this.plexFile;
|
||||
if(this.settings.streamPath === 'direct') {
|
||||
fs.access(this.file, fs.F_OK, (err) => {
|
||||
if (err) {
|
||||
throw Error("Can't access this file", err);
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
if (typeof(stream.streamUrl) == 'undefined') {
|
||||
throw Error("Direct path playback is not possible for this program because it was registered at a time when the direct path settings were not set. To fix this, you must either revert the direct path setting or rebuild this channel.");
|
||||
}
|
||||
} else if (this.isVideoDirectStream() === false) {
|
||||
this.log("Decision: File can direct play")
|
||||
this.log("Decision: Should transcode")
|
||||
// Change transcoding arguments to be the user chosen transcode parameters
|
||||
this.setTranscodingArgs(stream.directPlay, false, deinterlace)
|
||||
this.setTranscodingArgs(stream.directPlay, false, deinterlace, this.mediaHasNoVideo)
|
||||
// Update transcode decision for session
|
||||
await this.getDecision(stream.directPlay);
|
||||
stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}`
|
||||
} else {
|
||||
//This case sounds complex. Apparently plex is sending us just the audio, so we would need to get the video in a separate stream.
|
||||
this.log("Decision: Direct stream. Audio is being transcoded")
|
||||
stream.separateVideoStream = (this.settings.streamPath === 'direct') ? this.file : this.plexFile;
|
||||
stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}`
|
||||
this.directInfo = await this.getDirectInfo();
|
||||
this.videoIsDirect = true;
|
||||
}
|
||||
stream.streamStats = this.getVideoStats();
|
||||
|
||||
// use correct audio stream if direct play
|
||||
let audioIndex = await this.getAudioIndex();
|
||||
stream.streamStats.audioIndex = (stream.directPlay) ? audioIndex : 'a'
|
||||
stream.streamStats.audioIndex = (stream.directPlay) ? ( await this.getAudioIndex() ) : 'a'
|
||||
|
||||
this.log(stream)
|
||||
|
||||
return stream
|
||||
}
|
||||
|
||||
setTranscodingArgs(directPlay, directStream, deinterlace) {
|
||||
setTranscodingArgs(directPlay, directStream, deinterlace, audioOnly) {
|
||||
let resolution = (directStream) ? this.settings.maxPlayableResolution : this.settings.maxTranscodeResolution
|
||||
let bitrate = (directStream) ? this.settings.directStreamBitrate : this.settings.transcodeBitrate
|
||||
let mediaBufferSize = (directStream) ? this.settings.mediaBufferSize : this.settings.transcodeMediaBufferSize
|
||||
let subtitles = (this.settings.enableSubtitles) ? "burn" : "none" // subtitle options: burn, none, embedded, sidecar
|
||||
let streamContainer = "mpegts" // Other option is mkv, mkv has the option of copying it's subs for later processing
|
||||
let isDirectPlay = (directPlay) ? '1' : '0'
|
||||
let isDirectPlay = (directPlay) ? '1' : '0';
|
||||
let hasMDE = '1';
|
||||
|
||||
let videoQuality=`100` // Not sure how this applies, maybe this works if maxVideoBitrate is not set
|
||||
let profileName=`Generic` // Blank profile, everything is specified through X-Plex-Client-Profile-Extra
|
||||
@ -104,12 +141,17 @@ class PlexTranscoder {
|
||||
vc = "av1";
|
||||
}
|
||||
|
||||
let clientProfile=`add-transcode-target(type=videoProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&videoCodec=${vc}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)+\
|
||||
let clientProfile ="";
|
||||
if (! audioOnly ) {
|
||||
clientProfile=`add-transcode-target(type=videoProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&videoCodec=${vc}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)+\
|
||||
add-transcode-target-settings(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&CopyMatroskaAttachments=true)+\
|
||||
add-transcode-target-settings(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&BreakNonKeyframes=true)+\
|
||||
add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.width&value=${resolutionArr[0]})+\
|
||||
add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.height&value=${resolutionArr[1]})`
|
||||
|
||||
} else {
|
||||
clientProfile=`add-transcode-target(type=musicProfile&protocol=${this.settings.streamProtocol}&container=${streamContainer}&audioCodec=${this.settings.audioCodecs}&subtitleCodec=&context=streaming&replace=true)`
|
||||
|
||||
}
|
||||
// Set transcode settings per audio codec
|
||||
this.settings.audioCodecs.split(",").forEach(function (codec) {
|
||||
clientProfile+=`+add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=${this.settings.streamProtocol}&audioCodec=${codec})`
|
||||
@ -127,14 +169,18 @@ add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.height&va
|
||||
|
||||
let clientProfile_enc=encodeURIComponent(clientProfile)
|
||||
this.transcodingArgs=`X-Plex-Platform=${profileName}&\
|
||||
X-Plex-Product=${this.product}&\
|
||||
X-Plex-Client-Platform=${profileName}&\
|
||||
X-Plex-Client-Profile-Name=${profileName}&\
|
||||
X-Plex-Device-Name=${this.deviceName}&\
|
||||
X-Plex-Device=${this.device}&\
|
||||
X-Plex-Client-Identifier=${this.clientIdentifier}&\
|
||||
X-Plex-Platform=${profileName}&\
|
||||
X-Plex-Token=${this.server.accessToken}&\
|
||||
X-Plex-Client-Profile-Extra=${clientProfile_enc}&\
|
||||
protocol=${this.settings.streamProtocol}&\
|
||||
Connection=keep-alive&\
|
||||
hasMDE=1&\
|
||||
hasMDE=${hasMDE}&\
|
||||
path=${this.key}&\
|
||||
mediaIndex=0&\
|
||||
partIndex=0&\
|
||||
@ -159,16 +205,27 @@ lang=en`
|
||||
try {
|
||||
return this.getVideoStats().videoDecision === "copy";
|
||||
} catch (e) {
|
||||
console.log("Error at decision:" + e);
|
||||
console.error("Error at decision:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isAV1() {
|
||||
try {
|
||||
return this.getVideoStats().videoCodec === 'av1';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isDirectPlay() {
|
||||
try {
|
||||
if (this.getVideoStats().audioOnly) {
|
||||
return this.getVideoStats().audioDecision === "copy";
|
||||
}
|
||||
return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy";
|
||||
} catch (e) {
|
||||
console.log("Error at decision:" + e);
|
||||
console.error("Error at decision:" , e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -177,11 +234,26 @@ lang=en`
|
||||
let ret = {}
|
||||
try {
|
||||
let streams = this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].Stream
|
||||
|
||||
ret.duration = parseFloat( this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].duration );
|
||||
streams.forEach(function (stream) {
|
||||
streams.forEach(function (_stream, $index) {
|
||||
// Video
|
||||
let stream = _stream;
|
||||
if (stream["streamType"] == "1") {
|
||||
if ( this.videoIsDirect === true && typeof(this.directInfo) !== 'undefined') {
|
||||
stream = this.directInfo.MediaContainer.Metadata[0].Media[0].Part[0].Stream[$index];
|
||||
}
|
||||
ret.anamorphic = ( (stream.anamorphic === "1") || (stream.anamorphic === true) );
|
||||
if (ret.anamorphic) {
|
||||
let parsed = parsePixelAspectRatio(stream.pixelAspectRatio);
|
||||
if (isNaN(parsed.p) || isNaN(parsed.q) ) {
|
||||
throw Error("isNaN");
|
||||
}
|
||||
ret.pixelP = parsed.p;
|
||||
ret.pixelQ = parsed.q;
|
||||
} else {
|
||||
ret.pixelP= 1;
|
||||
ret.pixelQ = 1;
|
||||
}
|
||||
ret.videoCodec = stream.codec;
|
||||
ret.videoWidth = stream.width;
|
||||
ret.videoHeight = stream.height;
|
||||
@ -189,6 +261,7 @@ lang=en`
|
||||
// Rounding framerate avoids scenarios where
|
||||
// 29.9999999 & 30 don't match.
|
||||
ret.videoDecision = (typeof stream.decision === 'undefined') ? 'copy' : stream.decision;
|
||||
ret.videoScanType = stream.scanType;
|
||||
}
|
||||
// Audio. Only look at stream being used
|
||||
if (stream["streamType"] == "2" && stream["selected"] == "1") {
|
||||
@ -196,9 +269,17 @@ lang=en`
|
||||
ret.audioCodec = stream["codec"];
|
||||
ret.audioDecision = (typeof stream.decision === 'undefined') ? 'copy' : stream.decision;
|
||||
}
|
||||
})
|
||||
}.bind(this) )
|
||||
} catch (e) {
|
||||
console.log("Error at decision:" + e);
|
||||
console.error("Error at decision:" , e);
|
||||
}
|
||||
if (typeof(ret.videoCodec) === 'undefined') {
|
||||
ret.audioOnly = true;
|
||||
ret.placeholderImage = (this.albumArt.path != null) ?
|
||||
ret.placeholderImage = this.albumArt.path
|
||||
:
|
||||
ret.placeholderImage = `http://localhost:${process.env.PORT}/images/generic-music-screen.png`
|
||||
;
|
||||
}
|
||||
|
||||
this.log("Current video stats:")
|
||||
@ -225,11 +306,11 @@ lang=en`
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.log("Error at get media info:" + e);
|
||||
console.error("Error at get media info:" + e);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
console.error("Error getting audio index",err);
|
||||
});
|
||||
|
||||
this.log(`Found audio index: ${index}`)
|
||||
@ -237,24 +318,74 @@ lang=en`
|
||||
return index
|
||||
}
|
||||
|
||||
async getDecision(directPlay) {
|
||||
await axios.get(`${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`, {
|
||||
async getDirectInfo() {
|
||||
return (await axios.get(this.metadataPath) ).data;
|
||||
|
||||
}
|
||||
|
||||
async getDecisionUnmanaged(directPlay) {
|
||||
let url = `${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`;
|
||||
let res = await axios.get(url, {
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
.then((res) => {
|
||||
this.decisionJson = res.data;
|
||||
|
||||
this.log("Recieved transcode decision:")
|
||||
this.log("Received transcode decision:");
|
||||
this.log(res.data)
|
||||
|
||||
// Print error message if transcode not possible
|
||||
// TODO: handle failure better
|
||||
let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode
|
||||
if (!(directPlay || transcodeDecisionCode == "1001")) {
|
||||
console.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`)
|
||||
console.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`)
|
||||
if (res.data.MediaContainer.mdeDecisionCode === 1000) {
|
||||
this.log("mde decision code 1000, so it's all right?");
|
||||
return;
|
||||
}
|
||||
})
|
||||
|
||||
let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode;
|
||||
if (
|
||||
( typeof(transcodeDecisionCode) === 'undefined' )
|
||||
) {
|
||||
this.decisionJson.MediaContainer.transcodeDecisionCode = 'novideo';
|
||||
this.log("Strange case, attempt direct play");
|
||||
} else if (!(directPlay || transcodeDecisionCode == "1001")) {
|
||||
this.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`)
|
||||
this.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`)
|
||||
}
|
||||
}
|
||||
|
||||
async tryToDetectAudioOnly() {
|
||||
try {
|
||||
this.log("Try to detect audio only:");
|
||||
let url = `${this.server.uri}${this.key}?${this.transcodingArgs}`;
|
||||
let res = await axios.get(url, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
|
||||
let mediaContainer = res.data.MediaContainer;
|
||||
let metadata = getOneOrUndefined( mediaContainer, "Metadata");
|
||||
if (typeof(metadata) !== 'undefined') {
|
||||
this.albumArt.path = `${this.server.uri}${metadata.thumb}?X-Plex-Token=${this.server.accessToken}`;
|
||||
|
||||
let media = getOneOrUndefined( metadata, "Media");
|
||||
if (typeof(media) !== 'undefined') {
|
||||
if (typeof(media.videoCodec)==='undefined') {
|
||||
this.log("Audio-only file detected");
|
||||
this.mediaHasNoVideo = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error when getting album art", err);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
async getDecision(directPlay) {
|
||||
try {
|
||||
await this.getDecisionUnmanaged(directPlay);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
getStatusUrl() {
|
||||
@ -270,12 +401,13 @@ state=${this.playState}&\
|
||||
key=${this.key}&\
|
||||
time=${this.currTimeMs}&\
|
||||
duration=${this.duration}&\
|
||||
X-Plex-Product=${this.product}&\
|
||||
X-Plex-Platform=${profileName}&\
|
||||
X-Plex-Client-Platform=${profileName}&\
|
||||
X-Plex-Client-Profile-Name=${profileName}&\
|
||||
X-Plex-Device-Name=PseudoTV-Plex&\
|
||||
X-Plex-Device=PseudoTV-Plex&\
|
||||
X-Plex-Client-Identifier=${this.session}&\
|
||||
X-Plex-Device-Name=${this.deviceName}&\
|
||||
X-Plex-Device=${this.device}&\
|
||||
X-Plex-Client-Identifier=${this.clientIdentifier}&\
|
||||
X-Plex-Platform=${profileName}&\
|
||||
X-Plex-Token=${this.server.accessToken}`;
|
||||
|
||||
@ -299,8 +431,15 @@ X-Plex-Token=${this.server.accessToken}`;
|
||||
}
|
||||
|
||||
updatePlex() {
|
||||
this.log("Updating plex status")
|
||||
axios.post(this.getStatusUrl());
|
||||
this.log("Updating plex status");
|
||||
const statusUrl = this.getStatusUrl();
|
||||
try {
|
||||
axios.post(statusUrl);
|
||||
} catch (error) {
|
||||
this.log(`Problem updating Plex status using status URL ${statusUrl}:`);
|
||||
this.log(error);
|
||||
return false;
|
||||
}
|
||||
this.currTimeMs += this.updateInterval;
|
||||
if (this.currTimeMs > this.duration) {
|
||||
this.currTimeMs = this.duration;
|
||||
@ -315,4 +454,27 @@ X-Plex-Token=${this.server.accessToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function parsePixelAspectRatio(s) {
|
||||
let x = s.split(":");
|
||||
return {
|
||||
p: parseInt(x[0], 10),
|
||||
q: parseInt(x[1], 10),
|
||||
}
|
||||
}
|
||||
|
||||
function getOneOrUndefined(object, field) {
|
||||
if (typeof(object) === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
if ( typeof(object[field]) === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
let x = object[field];
|
||||
if (x.length < 1) {
|
||||
return undefined;
|
||||
}
|
||||
return x[0];
|
||||
}
|
||||
|
||||
module.exports = PlexTranscoder
|
||||
|
||||
@ -29,9 +29,25 @@ class ProgramPlayer {
|
||||
constructor( context ) {
|
||||
this.context = context;
|
||||
let program = context.lineupItem;
|
||||
if (program.err instanceof Error) {
|
||||
if (context.m3u8) {
|
||||
context.ffmpegSettings.normalizeAudio = false;
|
||||
// people might want the codec normalization to stay because of player support
|
||||
context.ffmpegSettings.normalizeResolution = false;
|
||||
}
|
||||
context.ffmpegSettings.noRealTime = program.noRealTime;
|
||||
if ( typeof(program.err) !== 'undefined') {
|
||||
console.log("About to play error stream");
|
||||
this.delegate = new OfflinePlayer(true, context);
|
||||
} else if (program.type === 'loading') {
|
||||
console.log("About to play loading stream");
|
||||
/* loading */
|
||||
context.isLoading = true;
|
||||
this.delegate = new OfflinePlayer(false, context);
|
||||
} else if (program.type === 'interlude') {
|
||||
console.log("About to play interlude stream");
|
||||
/* interlude */
|
||||
context.isInterlude = true;
|
||||
this.delegate = new OfflinePlayer(false, context);
|
||||
} else if (program.type === 'offline') {
|
||||
console.log("About to play offline stream");
|
||||
/* offline */
|
||||
@ -41,31 +57,20 @@ class ProgramPlayer {
|
||||
/* plex */
|
||||
this.delegate = new PlexPlayer(context);
|
||||
}
|
||||
this.context.enableChannelIcon = helperFuncs.isChannelIconEnabled( context.ffmpegSettings, context.channel, context.lineupItem.type);
|
||||
this.context.watermark = helperFuncs.getWatermark( context.ffmpegSettings, context.channel, context.lineupItem.type);
|
||||
}
|
||||
|
||||
cleanUp() {
|
||||
this.delegate.cleanUp();
|
||||
}
|
||||
|
||||
async playDelegate() {
|
||||
async playDelegate(outStream) {
|
||||
return await new Promise( async (accept, reject) => {
|
||||
setTimeout( () => {
|
||||
reject( Error("program player timed out before receiving any data.") );
|
||||
}, 30000);
|
||||
|
||||
|
||||
try {
|
||||
let stream = await this.delegate.play();
|
||||
let first = true;
|
||||
let stream = await this.delegate.play(outStream);
|
||||
accept(stream);
|
||||
let emitter = new EventEmitter();
|
||||
stream.on("data", (data) => {
|
||||
if (first) {
|
||||
accept( {stream: emitter, data: data} );
|
||||
first = false;
|
||||
} else {
|
||||
emitter.emit("data", data);
|
||||
}
|
||||
});
|
||||
function end() {
|
||||
reject( Error("Stream ended with no data") );
|
||||
stream.removeAllListeners("data");
|
||||
@ -85,9 +90,9 @@ class ProgramPlayer {
|
||||
}
|
||||
})
|
||||
}
|
||||
async play() {
|
||||
async play(outStream) {
|
||||
try {
|
||||
return await this.playDelegate();
|
||||
return await this.playDelegate(outStream);
|
||||
} catch(err) {
|
||||
if (! (err instanceof Error) ) {
|
||||
err= Error("Program player had an error before receiving any data. " + JSON.stringify(err) );
|
||||
@ -105,7 +110,7 @@ class ProgramPlayer {
|
||||
}
|
||||
this.delegate.cleanUp();
|
||||
this.delegate = new OfflinePlayer(true, this.context);
|
||||
return await this.play();
|
||||
return await this.play(outStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
src/services/active-channel-service.js
Normal file
@ -0,0 +1,150 @@
|
||||
|
||||
const constants = require("../constants");
|
||||
|
||||
/* Keeps track of which channels are being played, calls on-demand service
|
||||
when they stop playing.
|
||||
*/
|
||||
|
||||
class ActiveChannelService
|
||||
{
|
||||
/****
|
||||
*
|
||||
**/
|
||||
constructor(onDemandService, channelService ) {
|
||||
this.cache = {};
|
||||
this.onDemandService = onDemandService;
|
||||
this.onDemandService.setActiveChannelService(this);
|
||||
this.channelService = channelService;
|
||||
this.timeNoDelta = new Date().getTime();
|
||||
|
||||
this.loadChannelsForFirstTry();
|
||||
this.setupTimer();
|
||||
}
|
||||
|
||||
loadChannelsForFirstTry() {
|
||||
let fun = async() => {
|
||||
try {
|
||||
let numbers = await this.channelService.getAllChannelNumbers();
|
||||
numbers.forEach( (number) => {
|
||||
this.ensure(this.timeNoDelta, number);
|
||||
} );
|
||||
this.checkChannels();
|
||||
} catch (err) {
|
||||
console.error("Unexpected error when checking channels for the first time.", err);
|
||||
}
|
||||
}
|
||||
fun();
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
try {
|
||||
let t = new Date().getTime() - constants.FORGETFULNESS_BUFFER;
|
||||
for (const [channelNumber, value] of Object.entries(this.cache)) {
|
||||
console.log("Forcefully registering channel " + channelNumber + " as stopped...");
|
||||
delete this.cache[ channelNumber ];
|
||||
await this.onDemandService.registerChannelStopped( channelNumber, t , true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Unexpected error when shutting down active channels service.", err);
|
||||
}
|
||||
}
|
||||
|
||||
setupTimer() {
|
||||
this.handle = setTimeout( () => this.timerLoop(), constants.PLAYED_MONITOR_CHECK_FREQUENCY );
|
||||
}
|
||||
|
||||
checkChannel(t, channelNumber, value) {
|
||||
if (value.active === 0) {
|
||||
let delta = t - value.lastUpdate;
|
||||
if ( (delta >= constants.MAX_CHANNEL_IDLE) || (value.lastUpdate <= this.timeNoDelta) ) {
|
||||
console.log("Channel : " + channelNumber + " is not playing...");
|
||||
onDemandService.registerChannelStopped(channelNumber, value.stopTime);
|
||||
delete this.cache[channelNumber];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkChannels() {
|
||||
let t = new Date().getTime();
|
||||
for (const [channelNumber, value] of Object.entries(this.cache)) {
|
||||
this.checkChannel(t, channelNumber, value);
|
||||
}
|
||||
}
|
||||
|
||||
timerLoop() {
|
||||
try {
|
||||
this.checkChannels();
|
||||
} catch (err) {
|
||||
console.error("There was an error in active channel timer loop", err);
|
||||
} finally {
|
||||
this.setupTimer();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
registerChannelActive(t, channelNumber) {
|
||||
this.ensure(t, channelNumber);
|
||||
if (this.cache[channelNumber].active === 0) {
|
||||
console.log("Channel is being played: " + channelNumber );
|
||||
}
|
||||
this.cache[channelNumber].active++;
|
||||
//console.log(channelNumber + " ++active=" + this.cache[channelNumber].active );
|
||||
this.cache[channelNumber].stopTime = 0;
|
||||
this.cache[channelNumber].lastUpdate = new Date().getTime();
|
||||
}
|
||||
|
||||
registerChannelStopped(t, channelNumber) {
|
||||
this.ensure(t, channelNumber);
|
||||
if (this.cache[channelNumber].active === 1) {
|
||||
console.log("Register that channel is no longer being played: " + channelNumber );
|
||||
}
|
||||
if (this.cache[channelNumber].active === 0) {
|
||||
console.error("Serious issue with channel active service, double delete");
|
||||
} else {
|
||||
this.cache[channelNumber].active--;
|
||||
//console.log(channelNumber + " --active=" + this.cache[channelNumber].active );
|
||||
let s = this.cache[channelNumber].stopTime;
|
||||
if ( (typeof(s) === 'undefined') || (s < t) ) {
|
||||
this.cache[channelNumber].stopTime = t;
|
||||
}
|
||||
this.cache[channelNumber].lastUpdate = new Date().getTime();
|
||||
}
|
||||
}
|
||||
|
||||
ensure(t, channelNumber) {
|
||||
if (typeof(this.cache[channelNumber]) === 'undefined') {
|
||||
this.cache[channelNumber] = {
|
||||
active: 0,
|
||||
stopTime: t,
|
||||
lastUpdate: t,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peekChannel(t, channelNumber) {
|
||||
this.ensure(t, channelNumber);
|
||||
}
|
||||
|
||||
isActiveWrapped(channelNumber) {
|
||||
if (typeof(this.cache[channelNumber]) === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
if (typeof(this.cache[channelNumber].active) !== 'number') {
|
||||
return false;
|
||||
}
|
||||
return (this.cache[channelNumber].active !== 0);
|
||||
|
||||
}
|
||||
|
||||
isActive(channelNumber) {
|
||||
let bol = this.isActiveWrapped(channelNumber);
|
||||
return bol;
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = ActiveChannelService
|
||||
156
src/services/cache-image-service.js
Normal file
@ -0,0 +1,156 @@
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const request = require('request');
|
||||
|
||||
/**
|
||||
* Manager a cache in disk for external images.
|
||||
*
|
||||
* @class CacheImageService
|
||||
*/
|
||||
class CacheImageService {
|
||||
constructor( db, fileCacheService ) {
|
||||
this.cacheService = fileCacheService;
|
||||
this.imageCacheFolder = 'images';
|
||||
this.db = db['cache-images'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Router interceptor to download image and update cache before pass to express.static return this cached image.
|
||||
*
|
||||
* GET /:hash - Hash is a full external URL encoded in base64.
|
||||
* eg.: http://{host}/cache/images/aHR0cHM6Ly8xO...cXVUbmFVNDZQWS1LWQ==
|
||||
*
|
||||
* @returns
|
||||
* @memberof CacheImageService
|
||||
*/
|
||||
routerInterceptor() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:hash', async (req, res, next) => {
|
||||
try {
|
||||
const hash = req.params.hash;
|
||||
const imgItem = this.db.find({url: hash})[0];
|
||||
if(imgItem) {
|
||||
const file = await this.getImageFromCache(imgItem.url);
|
||||
if(!file.length) {
|
||||
const fileMimeType = await this.requestImageAndStore(Buffer.from(imgItem.url, 'base64').toString('ascii'), imgItem);
|
||||
res.set('content-type', fileMimeType);
|
||||
next();
|
||||
} else {
|
||||
res.set('content-type', imgItem.mimeType);
|
||||
next();
|
||||
}
|
||||
}
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
res.status(500).send("error");
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routers exported to use on express.use() function.
|
||||
* Use on api routers, like `{host}/api/cache/images`
|
||||
*
|
||||
* `DELETE /` - Clear all files on .dizquetv/cache/images
|
||||
*
|
||||
* @returns {Router}
|
||||
* @memberof CacheImageService
|
||||
*/
|
||||
apiRouters() {
|
||||
const router = express.Router();
|
||||
|
||||
router.delete('/', async (req, res, next) => {
|
||||
try {
|
||||
await this.clearCache();
|
||||
res.status(200).send({msg: 'Cache Image are Cleared'});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send("error");
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {*} url External URL to get file/image
|
||||
* @param {*} dbFile register of file from db
|
||||
* @returns {promise} `Resolve` when can download imagem and store on cache folder, `Reject` when file are inaccessible over network or can't write on directory
|
||||
* @memberof CacheImageService
|
||||
*/
|
||||
async requestImageAndStore(url, dbFile) {
|
||||
return new Promise( async(resolve, reject) => {
|
||||
const requestConfiguration = {
|
||||
method: 'get',
|
||||
url
|
||||
};
|
||||
|
||||
request(requestConfiguration, (err, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
const mimeType = res.headers['content-type'];
|
||||
this.db.update({_id: dbFile._id}, {url: dbFile.url, mimeType});
|
||||
request(requestConfiguration)
|
||||
.pipe(fs.createWriteStream(`${this.cacheService.cachePath}/${this.imageCacheFolder}/${dbFile.url}`))
|
||||
.on('close', () =>{
|
||||
resolve(mimeType);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image from cache using an filename
|
||||
*
|
||||
* @param {*} fileName
|
||||
* @returns {promise} `Resolve` with file content
|
||||
* @memberof CacheImageService
|
||||
*/
|
||||
getImageFromCache(fileName) {
|
||||
return new Promise(async(resolve, reject) => {
|
||||
try {
|
||||
const file = await this.cacheService.getCache(`${this.imageCacheFolder}/${fileName}`);
|
||||
resolve(file);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all files on .dizquetv/cache/images
|
||||
*
|
||||
* @returns {promise}
|
||||
* @memberof CacheImageService
|
||||
*/
|
||||
async clearCache() {
|
||||
return new Promise( async(resolve, reject) => {
|
||||
const cachePath = `${this.cacheService.cachePath}/${this.imageCacheFolder}`;
|
||||
fs.rmdir(cachePath, { recursive: true }, (err) => {
|
||||
if(err) {
|
||||
reject();
|
||||
}
|
||||
fs.mkdirSync(cachePath);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
registerImageOnDatabase(imageUrl) {
|
||||
const url = Buffer.from(imageUrl).toString('base64');
|
||||
const dbQuery = {url};
|
||||
if(!this.db.find(dbQuery)[0]) {
|
||||
this.db.save(dbQuery);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CacheImageService;
|
||||
103
src/services/channel-service.js
Normal file
@ -0,0 +1,103 @@
|
||||
const events = require('events')
|
||||
const channelCache = require("../channel-cache");
|
||||
|
||||
class ChannelService extends events.EventEmitter {
|
||||
|
||||
constructor(channelDB) {
|
||||
super();
|
||||
this.channelDB = channelDB;
|
||||
this.onDemandService = null;
|
||||
}
|
||||
|
||||
setOnDemandService(onDemandService) {
|
||||
this.onDemandService = onDemandService;
|
||||
}
|
||||
|
||||
async saveChannel(number, channelJson, options) {
|
||||
|
||||
let channel = cleanUpChannel(channelJson);
|
||||
let ignoreOnDemand = true;
|
||||
if (
|
||||
(this.onDemandService != null)
|
||||
&&
|
||||
( (typeof(options) === 'undefined') || (options.ignoreOnDemand !== true) )
|
||||
) {
|
||||
ignoreOnDemand = false;
|
||||
this.onDemandService.fixupChannelBeforeSave( channel );
|
||||
}
|
||||
channelCache.saveChannelConfig( number, channel);
|
||||
await channelDB.saveChannel( number, channel );
|
||||
|
||||
this.emit('channel-update', { channelNumber: number, channel: channel, ignoreOnDemand: ignoreOnDemand} );
|
||||
}
|
||||
|
||||
async deleteChannel(number) {
|
||||
await channelDB.deleteChannel( number );
|
||||
this.emit('channel-update', { channelNumber: number, channel: null} );
|
||||
|
||||
channelCache.clear();
|
||||
}
|
||||
|
||||
async getChannel(number) {
|
||||
let lis = await channelCache.getChannelConfig(this.channelDB, number)
|
||||
if ( lis == null || lis.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
return lis[0];
|
||||
}
|
||||
|
||||
async getAllChannelNumbers() {
|
||||
return await channelCache.getAllNumbers(this.channelDB);
|
||||
}
|
||||
|
||||
async getAllChannels() {
|
||||
return await channelCache.getAllChannels(this.channelDB);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function cleanUpProgram(program) {
|
||||
delete program.start
|
||||
delete program.stop
|
||||
delete program.streams;
|
||||
delete program.durationStr;
|
||||
delete program.commercials;
|
||||
if (
|
||||
(typeof(program.duration) === 'undefined')
|
||||
||
|
||||
(program.duration <= 0)
|
||||
) {
|
||||
console.error(`Input contained a program with invalid duration: ${program.duration}. This program has been deleted`);
|
||||
return [];
|
||||
}
|
||||
if (! Number.isInteger(program.duration) ) {
|
||||
console.error(`Input contained a program with invalid duration: ${program.duration}. Duration got fixed to be integer.`);
|
||||
program.duration = Math.ceil(program.duration);
|
||||
}
|
||||
return [ program ];
|
||||
}
|
||||
|
||||
function cleanUpChannel(channel) {
|
||||
if (
|
||||
(typeof(channel.groupTitle) === 'undefined')
|
||||
||
|
||||
(channel.groupTitle === '')
|
||||
) {
|
||||
channel.groupTitle = "dizqueTV";
|
||||
}
|
||||
channel.programs = channel.programs.flatMap( cleanUpProgram );
|
||||
delete channel.fillerContent;
|
||||
delete channel.filler;
|
||||
channel.fallback = channel.fallback.flatMap( cleanUpProgram );
|
||||
channel.duration = 0;
|
||||
for (let i = 0; i < channel.programs.length; i++) {
|
||||
channel.duration += channel.programs[i].duration;
|
||||
}
|
||||
return channel;
|
||||
|
||||
}
|
||||
|
||||
|
||||
module.exports = ChannelService
|
||||
47
src/services/event-service.js
Normal file
@ -0,0 +1,47 @@
|
||||
const EventEmitter = require("events");
|
||||
|
||||
class EventsService {
|
||||
constructor() {
|
||||
this.stream = new EventEmitter();
|
||||
let that = this;
|
||||
let fun = () => {
|
||||
that.push( "heartbeat", "{}");
|
||||
setTimeout(fun, 5000)
|
||||
};
|
||||
fun();
|
||||
|
||||
}
|
||||
|
||||
setup(app) {
|
||||
app.get("/api/events", (request, response) => {
|
||||
console.log("Open event channel.");
|
||||
response.writeHead(200, {
|
||||
"Content-Type" : "text/event-stream",
|
||||
"Cache-Control" : "no-cache",
|
||||
"connection" : "keep-alive",
|
||||
} );
|
||||
let listener = (event,data) => {
|
||||
//console.log( String(event) + " " + JSON.stringify(data) );
|
||||
response.write("event: " + String(event) + "\ndata: "
|
||||
+ JSON.stringify(data) + "\nretry: 5000\n\n" );
|
||||
};
|
||||
|
||||
this.stream.on("push", listener );
|
||||
response.on( "close", () => {
|
||||
console.log("Remove event channel.");
|
||||
this.stream.removeListener("push", listener);
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
push(event, data) {
|
||||
if (typeof(data.message) !== 'undefined') {
|
||||
console.log("Push event: " + data.message );
|
||||
}
|
||||
this.stream.emit("push", event, data );
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = EventsService;
|
||||
123
src/services/ffmpeg-settings-service.js
Normal file
@ -0,0 +1,123 @@
|
||||
const databaseMigration = require('../database-migration');
|
||||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
class FfmpegSettingsService {
|
||||
constructor(db, unlock) {
|
||||
this.db = db;
|
||||
if (unlock) {
|
||||
this.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
get() {
|
||||
let ffmpeg = this.getCurrentState();
|
||||
if (isLocked(ffmpeg)) {
|
||||
ffmpeg.lock = true;
|
||||
}
|
||||
// Hid this info from the API
|
||||
delete ffmpeg.ffmpegPathLockDate;
|
||||
return ffmpeg;
|
||||
}
|
||||
|
||||
unlock() {
|
||||
let ffmpeg = this.getCurrentState();
|
||||
console.log("ffmpeg path UI unlocked for another day...");
|
||||
ffmpeg.ffmpegPathLockDate = new Date().getTime() + DAY_MS;
|
||||
this.db['ffmpeg-settings'].update({ _id: ffmpeg._id }, ffmpeg)
|
||||
}
|
||||
|
||||
|
||||
update(attempt) {
|
||||
let ffmpeg = this.getCurrentState();
|
||||
attempt.ffmpegPathLockDate = ffmpeg.ffmpegPathLockDate;
|
||||
if (isLocked(ffmpeg)) {
|
||||
console.log("Note: ffmpeg path is not being updated since it's been locked for your security.");
|
||||
attempt.ffmpegPath = ffmpeg.ffmpegPath;
|
||||
if (typeof(ffmpeg.ffmpegPathLockDate) === 'undefined') {
|
||||
// make sure to lock it even if it was undefined
|
||||
attempt.ffmpegPathLockDate = new Date().getTime() - DAY_MS;
|
||||
}
|
||||
} else if (attempt.addLock === true) {
|
||||
// lock it right now
|
||||
attempt.ffmpegPathLockDate = new Date().getTime() - DAY_MS;
|
||||
} else {
|
||||
attempt.ffmpegPathLockDate = new Date().getTime() + DAY_MS;
|
||||
}
|
||||
delete attempt.addLock;
|
||||
delete attempt.lock;
|
||||
|
||||
let err = fixupFFMPEGSettings(attempt);
|
||||
if ( typeof(err) !== "undefined" ) {
|
||||
return {
|
||||
error: err
|
||||
}
|
||||
}
|
||||
|
||||
this.db['ffmpeg-settings'].update({ _id: ffmpeg._id }, attempt)
|
||||
return {
|
||||
ffmpeg: this.get()
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
// Even if reseting, it's impossible to unlock the ffmpeg path
|
||||
let ffmpeg = databaseMigration.defaultFFMPEG() ;
|
||||
this.update(ffmpeg);
|
||||
return this.get();
|
||||
}
|
||||
|
||||
getCurrentState() {
|
||||
return this.db['ffmpeg-settings'].find()[0]
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
function fixupFFMPEGSettings(ffmpeg) {
|
||||
if (typeof(ffmpeg.ffmpegPath) !== 'string') {
|
||||
return "ffmpeg path is required."
|
||||
}
|
||||
if (! isValidFilePath(ffmpeg.ffmpegPath)) {
|
||||
return "ffmpeg path must be a valid file path."
|
||||
}
|
||||
|
||||
if (typeof(ffmpeg.maxFPS) === 'undefined') {
|
||||
ffmpeg.maxFPS = 60;
|
||||
return null;
|
||||
} else if ( isNaN(ffmpeg.maxFPS) ) {
|
||||
return "maxFPS should be a number";
|
||||
}
|
||||
}
|
||||
|
||||
//These checks are good but might not be enough, as long as we are letting the
|
||||
//user choose any path and we are making dizqueTV execute, it is too risky,
|
||||
//hence why we are also adding the lock feature on top of these checks.
|
||||
function isValidFilePath(filePath) {
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(normalizedPath);
|
||||
return stats.isFile();
|
||||
} catch (err) {
|
||||
// Handle potential errors (e.g., file not found, permission issues)
|
||||
if (err.code === 'ENOENT') {
|
||||
return false; // File does not exist
|
||||
} else {
|
||||
throw err; // Re-throw other errors for debugging
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLocked(ffmpeg) {
|
||||
return isNaN(ffmpeg.ffmpegPathLockDate) || ffmpeg.ffmpegPathLockDate < new Date().getTime();
|
||||
}
|
||||
|
||||
|
||||
|
||||
module.exports =FfmpegSettingsService;
|
||||
97
src/services/file-cache-service.js
Normal file
@ -0,0 +1,97 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* Store files in cache
|
||||
*
|
||||
* @class FileCacheService
|
||||
*/
|
||||
class FileCacheService {
|
||||
constructor(cachePath) {
|
||||
this.cachePath = cachePath;
|
||||
this.cache = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* `save` a file on cache folder
|
||||
*
|
||||
* @param {string} fullFilePath
|
||||
* @param {*} data
|
||||
* @returns {promise}
|
||||
* @memberof CacheService
|
||||
*/
|
||||
setCache(fullFilePath, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const file = fs.createWriteStream(path.join(this.cachePath, fullFilePath));
|
||||
file.write(data, (err) => {
|
||||
if(err) {
|
||||
throw Error("Can't save file: ", err);
|
||||
} else {
|
||||
this.cache[fullFilePath] = data;
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `get` a File from cache folder
|
||||
*
|
||||
* @param {string} fullFilePath
|
||||
* @returns {promise} `Resolve` with file content, `Reject` with false
|
||||
* @memberof CacheService
|
||||
*/
|
||||
getCache(fullFilePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
if(fullFilePath in this.cache) {
|
||||
resolve(this.cache[fullFilePath]);
|
||||
} else {
|
||||
fs.readFile(path.join(this.cachePath, fullFilePath), 'utf8', function (err,data) {
|
||||
if (err) {
|
||||
resolve(false);
|
||||
}
|
||||
resolve(data);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
resolve(false);
|
||||
throw Error("Can't get file", error)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `delete` a File from cache folder
|
||||
*
|
||||
* @param {string} fullFilePath
|
||||
* @returns {promise}
|
||||
* @memberof CacheService
|
||||
*/
|
||||
deleteCache(fullFilePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
let thePath = path.join(this.cachePath, fullFilePath);
|
||||
if (! fs.existsSync(thePath)) {
|
||||
return resolve(true);
|
||||
}
|
||||
fs.unlinkSync(thePath, (err) => {
|
||||
if(err) {
|
||||
throw Error("Can't save file: ", err);
|
||||
} else {
|
||||
delete this.cache[fullFilePath];
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FileCacheService;
|
||||
68
src/services/get-show-data.js
Normal file
@ -0,0 +1,68 @@
|
||||
//This is an exact copy of the file with the same now in the web project
|
||||
//one of these days, we'll figure out how to share the code.
|
||||
module.exports = function () {
|
||||
|
||||
let movieTitleOrder = {};
|
||||
let movieTitleOrderNumber = 0;
|
||||
|
||||
return (program) => {
|
||||
if ( typeof(program.customShowId) !== 'undefined' ) {
|
||||
return {
|
||||
hasShow : true,
|
||||
showId : "custom." + program.customShowId,
|
||||
showDisplayName : program.customShowName,
|
||||
order : program.customOrder,
|
||||
shuffleOrder : program.shuffleOrder,
|
||||
}
|
||||
} else if (program.isOffline && program.type === 'redirect') {
|
||||
return {
|
||||
hasShow : true,
|
||||
showId : "redirect." + program.channel,
|
||||
order : program.duration,
|
||||
showDisplayName : `Redirect to channel ${program.channel}`,
|
||||
channel: program.channel,
|
||||
}
|
||||
} else if (program.isOffline) {
|
||||
return {
|
||||
hasShow : false
|
||||
}
|
||||
} else if (program.type === 'movie') {
|
||||
let key = program.serverKey + "|" + program.key;
|
||||
if (typeof(movieTitleOrder[key]) === 'undefined') {
|
||||
movieTitleOrder[key] = movieTitleOrderNumber++;
|
||||
}
|
||||
return {
|
||||
hasShow : true,
|
||||
showId : "movie.",
|
||||
showDisplayName : "Movies",
|
||||
order : movieTitleOrder[key],
|
||||
shuffleOrder : program.shuffleOrder,
|
||||
}
|
||||
} else if ( (program.type === 'episode') || (program.type === 'track') ) {
|
||||
let s = 0;
|
||||
let e = 0;
|
||||
if ( typeof(program.season) !== 'undefined') {
|
||||
s = program.season;
|
||||
}
|
||||
if ( typeof(program.episode) !== 'undefined') {
|
||||
e = program.episode;
|
||||
}
|
||||
let prefix = "tv.";
|
||||
if (program.type === 'track') {
|
||||
prefix = "audio.";
|
||||
}
|
||||
return {
|
||||
hasShow: true,
|
||||
showId : prefix + program.showTitle,
|
||||
showDisplayName : program.showTitle,
|
||||
order : s * 1000000 + e,
|
||||
shuffleOrder : program.shuffleOrder,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
hasShow : false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
97
src/services/m3u-service.js
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Manager and Generate M3U content
|
||||
*
|
||||
* @class M3uService
|
||||
*/
|
||||
class M3uService {
|
||||
constructor(fileCacheService, channelService) {
|
||||
this.channelService = channelService;
|
||||
this.cacheService = fileCacheService;
|
||||
this.cacheReady = false;
|
||||
this.channelService.on("channel-update", (data) => {
|
||||
this.clearCache();
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channel list in HLS or M3U
|
||||
*
|
||||
* @param {string} [type='m3u'] List type
|
||||
* @returns {promise} Return a Promise with HLS or M3U file content
|
||||
* @memberof M3uService
|
||||
*/
|
||||
getChannelList(host) {
|
||||
return this.buildM3uList(host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build M3U with cache
|
||||
*
|
||||
* @param {string} host
|
||||
* @returns {promise} M3U file content
|
||||
* @memberof M3uService
|
||||
*/
|
||||
|
||||
async buildM3uList(host) {
|
||||
if (this.cacheReady) {
|
||||
const cachedM3U = await this.cacheService.getCache('channels.m3u');
|
||||
if (cachedM3U) {
|
||||
return this.replaceHostOnM3u(host, cachedM3U);
|
||||
}
|
||||
}
|
||||
let channels = await this.channelService.getAllChannels();
|
||||
|
||||
|
||||
channels.sort((a, b) => {
|
||||
return parseInt(a.number) < parseInt(b.number) ? -1 : 1
|
||||
});
|
||||
|
||||
const tvg = `{{host}}/api/xmltv.xml`;
|
||||
|
||||
let data = `#EXTM3U url-tvg="${tvg}" x-tvg-url="${tvg}"\n`;
|
||||
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
if (channels[i].stealth !== true) {
|
||||
data += `#EXTINF:0 tvg-id="${channels[i].number}" CUID="${channels[i].number}" tvg-chno="${channels[i].number}" tvg-name="${channels[i].name}" tvg-logo="${channels[i].icon}" group-title="${channels[i].groupTitle}",${channels[i].name}\n`
|
||||
data += `{{host}}/video?channel=${channels[i].number}\n`
|
||||
}
|
||||
}
|
||||
if (channels.length === 0) {
|
||||
data += `#EXTINF:0 tvg-id="1" tvg-chno="1" tvg-name="dizqueTV" tvg-logo="{{host}}/resources/dizquetv.png" group-title="dizqueTV",dizqueTV\n`
|
||||
data += `{{host}}/setup\n`
|
||||
}
|
||||
let saveCacheThread = async() => {
|
||||
try {
|
||||
await this.cacheService.setCache('channels.m3u', data);
|
||||
this.cacheReady = true;
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
saveCacheThread();
|
||||
return this.replaceHostOnM3u(host, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace {{host}} string with a URL on file contents.
|
||||
*
|
||||
* @param {*} host
|
||||
* @param {*} data
|
||||
* @returns
|
||||
* @memberof M3uService
|
||||
*/
|
||||
replaceHostOnM3u(host, data) {
|
||||
return data.replace(/\{\{host\}\}/g, host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear channels.m3u file from cache folder.
|
||||
*
|
||||
* @memberof M3uService
|
||||
*/
|
||||
async clearCache() {
|
||||
this.cacheReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = M3uService;
|
||||
226
src/services/on-demand-service.js
Normal file
@ -0,0 +1,226 @@
|
||||
|
||||
const constants = require("../constants");
|
||||
|
||||
const SLACK = constants.SLACK;
|
||||
|
||||
|
||||
class OnDemandService
|
||||
{
|
||||
/****
|
||||
*
|
||||
**/
|
||||
constructor(channelService) {
|
||||
this.channelService = channelService;
|
||||
this.channelService.setOnDemandService(this);
|
||||
this.activeChannelService = null;
|
||||
}
|
||||
|
||||
setActiveChannelService(activeChannelService) {
|
||||
this.activeChannelService = activeChannelService;
|
||||
}
|
||||
|
||||
activateChannelIfNeeded(moment, channel) {
|
||||
if ( this.isOnDemandChannelPaused(channel) ) {
|
||||
channel = this.resumeOnDemandChannel(moment, channel);
|
||||
this.updateChannelAsync(channel);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
async registerChannelStopped(channelNumber, stopTime, waitForSave) {
|
||||
try {
|
||||
let channel = await this.channelService.getChannel(channelNumber);
|
||||
if (channel == null) {
|
||||
console.error("Could not stop channel " + channelNumber + " because it apparently no longer exists"); // I guess if someone deletes the channel just in the grace period?
|
||||
return
|
||||
}
|
||||
|
||||
if ( (typeof(channel.onDemand) !== 'undefined') && channel.onDemand.isOnDemand && ! channel.onDemand.paused) {
|
||||
//pause the channel
|
||||
channel = this.pauseOnDemandChannel( channel , stopTime );
|
||||
if (waitForSave) {
|
||||
await this.updateChannelSync(channel);
|
||||
} else {
|
||||
this.updateChannelAsync(channel);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error stopping channel", err);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
pauseOnDemandChannel(originalChannel, stopTime) {
|
||||
console.log("Pause on-demand channel : " + originalChannel.number);
|
||||
let channel = clone(originalChannel);
|
||||
// first find what the heck is playing
|
||||
let t = stopTime;
|
||||
let s = new Date(channel.startTime).getTime();
|
||||
let onDemand = channel.onDemand;
|
||||
onDemand.paused = true;
|
||||
if ( channel.programs.length == 0) {
|
||||
console.log("On-demand channel has no programs. That doesn't really make a lot of sense...");
|
||||
onDemand.firstProgramModulo = s % onDemand.modulo;
|
||||
onDemand.playedOffset = 0;
|
||||
|
||||
} else if (t < s) {
|
||||
// the first program didn't even play.
|
||||
onDemand.firstProgramModulo = s % onDemand.modulo;
|
||||
onDemand.playedOffset = 0;
|
||||
} else {
|
||||
let i = 0;
|
||||
let total = 0;
|
||||
while (true) {
|
||||
let d = channel.programs[i].duration;
|
||||
if ( (s + total <= t) && (t < s + total + d) ) {
|
||||
break;
|
||||
}
|
||||
total += d;
|
||||
i = (i + 1) % channel.programs.length;
|
||||
}
|
||||
// rotate
|
||||
let programs = [];
|
||||
for (let j = i; j < channel.programs.length; j++) {
|
||||
programs.push( channel.programs[j] );
|
||||
}
|
||||
for (let j = 0; j <i; j++) {
|
||||
programs.push( channel.programs[j] );
|
||||
}
|
||||
onDemand.firstProgramModulo = (s + total) % onDemand.modulo;
|
||||
onDemand.playedOffset = t - (s + total);
|
||||
channel.programs = programs;
|
||||
channel.startTime = new Date(s + total).toISOString();
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
async updateChannelSync(channel) {
|
||||
try {
|
||||
await this.channelService.saveChannel(
|
||||
channel.number,
|
||||
channel,
|
||||
{ignoreOnDemand: true}
|
||||
);
|
||||
console.log("Channel " + channel.number + " saved by on-demand service...");
|
||||
} catch (err) {
|
||||
console.error("Error saving resumed channel: " + channel.number, err);
|
||||
}
|
||||
}
|
||||
|
||||
updateChannelAsync(channel) {
|
||||
this.updateChannelSync(channel);
|
||||
}
|
||||
|
||||
fixupChannelBeforeSave(channel) {
|
||||
let isActive = false;
|
||||
if (this.activeChannelService != null && this.activeChannelService.isActive(channel.number) ) {
|
||||
isActive = true;
|
||||
}
|
||||
if (typeof(channel.onDemand) === 'undefined') {
|
||||
channel.onDemand = {};
|
||||
}
|
||||
if (typeof(channel.onDemand.isOnDemand) !== 'boolean') {
|
||||
channel.onDemand.isOnDemand = false;
|
||||
}
|
||||
if ( channel.onDemand.isOnDemand !== true ) {
|
||||
channel.onDemand.modulo = 1;
|
||||
channel.onDemand.firstProgramModulo = 1;
|
||||
channel.onDemand.playedOffset = 0;
|
||||
channel.onDemand.paused = false;
|
||||
} else {
|
||||
if ( typeof(channel.onDemand.modulo) !== 'number') {
|
||||
channel.onDemand.modulo = 1;
|
||||
}
|
||||
if (isActive) {
|
||||
// if it is active, the channel isn't paused
|
||||
channel.onDemand.paused = false;
|
||||
} else {
|
||||
let s = new Date(channel.startTime).getTime();
|
||||
channel.onDemand.paused = true;
|
||||
channel.onDemand.firstProgramModulo = s % channel.onDemand.modulo;
|
||||
channel.onDemand.playedOffset = 0;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
resumeOnDemandChannel(t, originalChannel) {
|
||||
let channel = clone(originalChannel);
|
||||
console.log("Resume on-demand channel: " + channel.name);
|
||||
let programs = channel.programs;
|
||||
let onDemand = channel.onDemand;
|
||||
onDemand.paused = false; //should be the invariant
|
||||
if (programs.length == 0) {
|
||||
console.log("On-demand channel is empty. This doesn't make a lot of sense...");
|
||||
return channel;
|
||||
}
|
||||
let i = 0;
|
||||
let backupFo = onDemand.firstProgramModulo;
|
||||
|
||||
while (i < programs.length) {
|
||||
let program = programs[i];
|
||||
if ( program.isOffline && (program.type !== 'redirect') ) {
|
||||
//skip flex
|
||||
i++;
|
||||
onDemand.playedOffset = 0;
|
||||
onDemand.firstProgramModulo = ( onDemand.firstProgramModulo + program.duration ) % onDemand.modulo;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (i == programs.length) {
|
||||
console.log("Everything in the channel is flex... This doesn't really make a lot of sense for an onDemand channel, you know...");
|
||||
i = 0;
|
||||
onDemand.playedOffset = 0;
|
||||
onDemand.firstProgramModulo = backupFo;
|
||||
}
|
||||
// Last we've seen this channel, it was playing program #i , played the first playedOffset milliseconds.
|
||||
// move i to the beginning of the program list
|
||||
let newPrograms = []
|
||||
for (let j = i; j < programs.length; j++) {
|
||||
newPrograms.push( programs[j] );
|
||||
}
|
||||
for (let j = 0; j < i; j++) {
|
||||
newPrograms.push( programs[j] );
|
||||
}
|
||||
// now the start program is 0, and the "only" thing to do now is change the start time
|
||||
let startTime = t - onDemand.playedOffset;
|
||||
// with this startTime, it would work perfectly if modulo is 1. But what about other cases?
|
||||
|
||||
let tm = t % onDemand.modulo;
|
||||
let pm = (onDemand.firstProgramModulo + onDemand.playedOffset) % onDemand.modulo;
|
||||
|
||||
if (tm < pm) {
|
||||
startTime += (pm - tm);
|
||||
} else {
|
||||
let o = (tm - pm);
|
||||
startTime = startTime - o;
|
||||
//It looks like it is convenient to make the on-demand a bit more lenient SLACK-wise tha
|
||||
//other parts of the schedule process. So SLACK*2 instead of just SLACK
|
||||
if (o >= SLACK*2) {
|
||||
startTime += onDemand.modulo;
|
||||
}
|
||||
}
|
||||
channel.startTime = (new Date(startTime)).toISOString();
|
||||
channel.programs = newPrograms;
|
||||
return channel;
|
||||
}
|
||||
|
||||
isOnDemandChannelPaused(channel) {
|
||||
return (
|
||||
(typeof(channel.onDemand) !== 'undefined')
|
||||
&&
|
||||
(channel.onDemand.isOnDemand === true)
|
||||
&&
|
||||
(channel.onDemand.paused === true)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
function clone(channel) {
|
||||
return JSON.parse( JSON.stringify(channel) );
|
||||
}
|
||||
|
||||
module.exports = OnDemandService
|
||||
35
src/services/programming-service.js
Normal file
@ -0,0 +1,35 @@
|
||||
|
||||
const helperFuncs = require("../helperFuncs");
|
||||
|
||||
/* Tells us what is or should be playing in some channel
|
||||
If the channel is a an on-demand channel and is paused, resume the channel.
|
||||
Before running the logic.
|
||||
|
||||
This hub for the programming logic used to be helperFuncs.getCurrentProgramAndTimeElapsed.
|
||||
|
||||
This class will still call that function, but this should be the entry point
|
||||
for that logic.
|
||||
|
||||
Eventually it looks like a good idea to move that logic here.
|
||||
|
||||
*/
|
||||
|
||||
class ProgrammingService
|
||||
{
|
||||
/****
|
||||
*
|
||||
**/
|
||||
constructor(onDemandService) {
|
||||
this.onDemandService = onDemandService;
|
||||
}
|
||||
|
||||
getCurrentProgramAndTimeElapsed(moment, channel) {
|
||||
channel = onDemandService.activateChannelIfNeeded(moment, channel);
|
||||
return helperFuncs.getCurrentProgramAndTimeElapsed(moment, channel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = ProgrammingService
|
||||
349
src/services/random-slots-service.js
Normal file
@ -0,0 +1,349 @@
|
||||
const constants = require("../constants");
|
||||
const getShowData = require("./get-show-data")();
|
||||
const random = require('../helperFuncs').random;
|
||||
const throttle = require('./throttle');
|
||||
const orderers = require("./show-orderers");
|
||||
|
||||
const MINUTE = 60*1000;
|
||||
const DAY = 24*60*MINUTE;
|
||||
const LIMIT = 40000;
|
||||
|
||||
|
||||
|
||||
function getShow(program) {
|
||||
|
||||
let d = getShowData(program);
|
||||
if (! d.hasShow) {
|
||||
return null;
|
||||
} else {
|
||||
d.description = d.showDisplayName;
|
||||
d.id = d.showId;
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
function getProgramId(program) {
|
||||
let s = program.serverKey;
|
||||
if (typeof(s) === 'undefined') {
|
||||
s = 'unknown';
|
||||
}
|
||||
let p = program.key;
|
||||
if (typeof(p) === 'undefined') {
|
||||
p = 'unknown';
|
||||
}
|
||||
return s + "|" + p;
|
||||
}
|
||||
|
||||
function addProgramToShow(show, program) {
|
||||
if ( (show.id == 'flex.') || show.id.startsWith("redirect.") ) {
|
||||
//nothing to do
|
||||
return;
|
||||
}
|
||||
let id = getProgramId(program)
|
||||
if(show.programs[id] !== true) {
|
||||
show.programs.push(program);
|
||||
show.programs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = async( programs, schedule ) => {
|
||||
if (! Array.isArray(programs) ) {
|
||||
return { userError: 'Expected a programs array' };
|
||||
}
|
||||
if (typeof(schedule) === 'undefined') {
|
||||
return { userError: 'Expected a schedule' };
|
||||
}
|
||||
//verify that the schedule is in the correct format
|
||||
if (! Array.isArray(schedule.slots) ) {
|
||||
return { userError: 'Expected a "slots" array in schedule' };
|
||||
}
|
||||
if (typeof(schedule).period === 'undefined') {
|
||||
schedule.period = DAY;
|
||||
}
|
||||
for (let i = 0; i < schedule.slots.length; i++) {
|
||||
if (typeof(schedule.slots[i].duration) === 'undefined') {
|
||||
return { userError: "Each slot should have a duration" };
|
||||
}
|
||||
if (typeof(schedule.slots[i].showId) === 'undefined') {
|
||||
return { userError: "Each slot should have a showId" };
|
||||
}
|
||||
if (
|
||||
(schedule.slots[i].duration <= 0)
|
||||
|| (Math.floor(schedule.slots[i].duration) != schedule.slots[i].duration)
|
||||
) {
|
||||
return { userError: "Slot duration should be a integer number of milliseconds greater than 0" };
|
||||
}
|
||||
if ( isNaN(schedule.slots[i].cooldown) ) {
|
||||
schedule.slots[i].cooldown = 0;
|
||||
}
|
||||
if ( isNaN(schedule.slots[i].weight) ) {
|
||||
schedule.slots[i].weight = 1;
|
||||
}
|
||||
}
|
||||
if (typeof(schedule.pad) === 'undefined') {
|
||||
return { userError: "Expected schedule.pad" };
|
||||
}
|
||||
if (typeof(schedule.maxDays) == 'undefined') {
|
||||
return { userError: "schedule.maxDays must be defined." };
|
||||
}
|
||||
if (typeof(schedule.flexPreference) === 'undefined') {
|
||||
schedule.flexPreference = "distribute";
|
||||
}
|
||||
if (typeof(schedule.padStyle) === 'undefined') {
|
||||
schedule.padStyle = "slot";
|
||||
}
|
||||
if (schedule.padStyle !== "slot" && schedule.padStyle !== "episode") {
|
||||
return { userError: `Invalid schedule.padStyle value: "${schedule.padStyle}"` };
|
||||
}
|
||||
let flexBetween = ( schedule.flexPreference !== "end" );
|
||||
|
||||
let showsById = {};
|
||||
let shows = [];
|
||||
|
||||
function getNextForSlot(slot, remaining) {
|
||||
//remaining doesn't restrict what next show is picked. It is only used
|
||||
//for shows with flexible length (flex and redirects)
|
||||
if (slot.showId === "flex.") {
|
||||
return {
|
||||
isOffline: true,
|
||||
duration: remaining,
|
||||
}
|
||||
}
|
||||
let show = shows[ showsById[slot.showId] ];
|
||||
if (slot.showId.startsWith("redirect.")) {
|
||||
return {
|
||||
isOffline: true,
|
||||
type: "redirect",
|
||||
duration: remaining,
|
||||
channel: show.channel,
|
||||
}
|
||||
} else if (slot.order === 'shuffle') {
|
||||
return orderers.getShowShuffler(show).current();
|
||||
} else if (slot.order === 'next') {
|
||||
return orderers.getShowOrderer(show).current();
|
||||
}
|
||||
}
|
||||
|
||||
function advanceSlot(slot) {
|
||||
if ( (slot.showId === "flex.") || (slot.showId.startsWith("redirect") ) ) {
|
||||
return;
|
||||
}
|
||||
let show = shows[ showsById[slot.showId] ];
|
||||
if (slot.order === 'shuffle') {
|
||||
return orderers.getShowShuffler(show).next();
|
||||
} else if (slot.order === 'next') {
|
||||
return orderers.getShowOrderer(show).next();
|
||||
}
|
||||
}
|
||||
|
||||
function makePadded(item) {
|
||||
let padOption = schedule.pad;
|
||||
if (schedule.padStyle === "slot") {
|
||||
padOption = 1;
|
||||
}
|
||||
let x = item.duration;
|
||||
let m = x % padOption;
|
||||
let f = 0;
|
||||
if ( (m > constants.SLACK) && (padOption - m > constants.SLACK) ) {
|
||||
f = padOption - m;
|
||||
}
|
||||
return {
|
||||
item: item,
|
||||
pad: f,
|
||||
totalDuration: item.duration + f,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// load the programs
|
||||
for (let i = 0; i < programs.length; i++) {
|
||||
let p = programs[i];
|
||||
let show = getShow(p);
|
||||
if (show != null) {
|
||||
if (typeof(showsById[show.id] ) === 'undefined') {
|
||||
showsById[show.id] = shows.length;
|
||||
shows.push( show );
|
||||
show.founder = p;
|
||||
show.programs = [];
|
||||
} else {
|
||||
show = shows[ showsById[show.id] ];
|
||||
}
|
||||
addProgramToShow( show, p );
|
||||
}
|
||||
}
|
||||
|
||||
let s = schedule.slots;
|
||||
let ts = (new Date() ).getTime();
|
||||
|
||||
let t0 = ts;
|
||||
let p = [];
|
||||
let t = t0;
|
||||
|
||||
let hardLimit = t0 + schedule.maxDays * DAY;
|
||||
|
||||
let pushFlex = (d) => {
|
||||
if (d > 0) {
|
||||
t += d;
|
||||
if ( (p.length > 0) && p[p.length-1].isOffline && (p[p.length-1].type != 'redirect') ) {
|
||||
p[p.length-1].duration += d;
|
||||
} else {
|
||||
p.push( {
|
||||
duration: d,
|
||||
isOffline : true,
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pushProgram = (item) => {
|
||||
if ( item.isOffline && (item.type !== 'redirect') ) {
|
||||
pushFlex(item.duration);
|
||||
} else {
|
||||
p.push(item);
|
||||
t += item.duration;
|
||||
}
|
||||
};
|
||||
|
||||
let slotLastPlayed = {};
|
||||
|
||||
while ( (t < hardLimit) && (p.length < LIMIT) ) {
|
||||
await throttle();
|
||||
//ensure t is padded
|
||||
let m = t % schedule.pad;
|
||||
if ( (t % schedule.pad > constants.SLACK) && (schedule.pad - m > constants.SLACK) ) {
|
||||
pushFlex( schedule.pad - m );
|
||||
continue;
|
||||
}
|
||||
|
||||
let slot = null;
|
||||
let slotIndex = null;
|
||||
let remaining = null;
|
||||
|
||||
let n = 0;
|
||||
let minNextTime = t + 24*DAY;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if ( typeof( slotLastPlayed[i] ) !== undefined ) {
|
||||
let lastt = slotLastPlayed[i];
|
||||
minNextTime = Math.min( minNextTime, lastt + s[i].cooldown );
|
||||
if (t - lastt < s[i].cooldown - constants.SLACK ) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
n += s[i].weight;
|
||||
if ( random.bool(s[i].weight,n) ) {
|
||||
slot = s[i];
|
||||
slotIndex = i;
|
||||
remaining = s[i].duration;
|
||||
}
|
||||
}
|
||||
if (slot == null) {
|
||||
//Nothing to play, likely due to cooldown
|
||||
pushFlex( minNextTime - t);
|
||||
continue;
|
||||
}
|
||||
let item = getNextForSlot(slot, remaining);
|
||||
|
||||
if (item.isOffline) {
|
||||
//flex or redirect. We can just use the whole duration
|
||||
item.duration = remaining;
|
||||
pushProgram(item);
|
||||
slotLastPlayed[ slotIndex ] = t;
|
||||
continue;
|
||||
}
|
||||
if (item.duration > remaining) {
|
||||
// Slide
|
||||
pushProgram(item);
|
||||
slotLastPlayed[ slotIndex ] = t;
|
||||
advanceSlot(slot);
|
||||
continue;
|
||||
}
|
||||
|
||||
let padded = makePadded(item);
|
||||
let total = padded.totalDuration;
|
||||
advanceSlot(slot);
|
||||
let pads = [ padded ];
|
||||
|
||||
while(true) {
|
||||
let item2 = getNextForSlot(slot);
|
||||
if (total + item2.duration > remaining) {
|
||||
break;
|
||||
}
|
||||
let padded2 = makePadded(item2);
|
||||
pads.push(padded2);
|
||||
advanceSlot(slot);
|
||||
total += padded2.totalDuration;
|
||||
}
|
||||
let temt = t + total;
|
||||
let rem = 0;
|
||||
if (
|
||||
(temt % schedule.pad >= constants.SLACK)
|
||||
&& (temt % schedule.pad < schedule.pad - constants.SLACK)
|
||||
) {
|
||||
rem = schedule.pad - temt % schedule.pad;
|
||||
}
|
||||
|
||||
|
||||
if (flexBetween && (schedule.padStyle === 'episode') ) {
|
||||
let div = Math.floor(rem / schedule.pad );
|
||||
let mod = rem % schedule.pad;
|
||||
// add mod to the latest item
|
||||
pads[ pads.length - 1].pad += mod;
|
||||
pads[ pads.length - 1].totalDuration += mod;
|
||||
|
||||
let sortedPads = pads.map( (p, $index) => {
|
||||
return {
|
||||
pad: p.pad,
|
||||
index : $index,
|
||||
}
|
||||
});
|
||||
sortedPads.sort( (a,b) => { return a.pad - b.pad; } );
|
||||
for (let i = 0; i < pads.length; i++) {
|
||||
let q = Math.floor( div / pads.length );
|
||||
if (i < div % pads.length) {
|
||||
q++;
|
||||
}
|
||||
let j = sortedPads[i].index;
|
||||
pads[j].pad += q * schedule.pad;
|
||||
}
|
||||
} else if (flexBetween) {
|
||||
//just distribute it equitatively
|
||||
let div = Math.floor( rem / pads.length );
|
||||
let totalAdded = 0;
|
||||
for (let i = 0; i < pads.length; i++) {
|
||||
pads[i].pad += div;
|
||||
totalAdded += div;
|
||||
}
|
||||
pads[0].pad += rem - totalAdded;
|
||||
|
||||
} else {
|
||||
//also add div to the latest item
|
||||
pads[ pads.length - 1].pad += rem;
|
||||
pads[ pads.length - 1].totalDuration += rem;
|
||||
}
|
||||
// now unroll them all
|
||||
for (let i = 0; i < pads.length; i++) {
|
||||
pushProgram( pads[i].item );
|
||||
slotLastPlayed[ slotIndex ] = t;
|
||||
pushFlex( pads[i].pad );
|
||||
}
|
||||
}
|
||||
while ( (t > hardLimit) || (p.length >= LIMIT) ) {
|
||||
t -= p.pop().duration;
|
||||
}
|
||||
let m = (t - t0) % schedule.period;
|
||||
if (m != 0) {
|
||||
//ensure the schedule is a multiple of period
|
||||
pushFlex( schedule.period - m);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
programs: p,
|
||||
startTime: (new Date(t0)).toISOString(),
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
156
src/services/show-orderers.js
Normal file
@ -0,0 +1,156 @@
|
||||
const random = require('../helperFuncs').random;
|
||||
const getShowData = require("./get-show-data")();
|
||||
const randomJS = require("random-js");
|
||||
const Random = randomJS.Random;
|
||||
|
||||
|
||||
|
||||
/****
|
||||
*
|
||||
* Code shared by random slots and time slots for keeping track of the order
|
||||
* of episodes
|
||||
*
|
||||
**/
|
||||
function shuffle(array, lo, hi, randomOverride ) {
|
||||
let r = randomOverride;
|
||||
if (typeof(r) === 'undefined') {
|
||||
r = random;
|
||||
}
|
||||
if (typeof(lo) === 'undefined') {
|
||||
lo = 0;
|
||||
hi = array.length;
|
||||
}
|
||||
let currentIndex = hi, temporaryValue, randomIndex
|
||||
while (lo !== currentIndex) {
|
||||
randomIndex = r.integer(lo, currentIndex-1);
|
||||
currentIndex -= 1
|
||||
temporaryValue = array[currentIndex]
|
||||
array[currentIndex] = array[randomIndex]
|
||||
array[randomIndex] = temporaryValue
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
|
||||
function getShowOrderer(show) {
|
||||
if (typeof(show.orderer) === 'undefined') {
|
||||
|
||||
let sortedPrograms = JSON.parse( JSON.stringify(show.programs) );
|
||||
sortedPrograms.sort((a, b) => {
|
||||
let showA = getShowData(a);
|
||||
let showB = getShowData(b);
|
||||
return showA.order - showB.order;
|
||||
});
|
||||
|
||||
let position = 0;
|
||||
while (
|
||||
(position + 1 < sortedPrograms.length )
|
||||
&&
|
||||
(
|
||||
getShowData(show.founder).order
|
||||
!==
|
||||
getShowData(sortedPrograms[position]).order
|
||||
)
|
||||
) {
|
||||
position++;
|
||||
}
|
||||
|
||||
|
||||
show.orderer = {
|
||||
|
||||
current : () => {
|
||||
return sortedPrograms[position];
|
||||
},
|
||||
|
||||
next: () => {
|
||||
position = (position + 1) % sortedPrograms.length;
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
return show.orderer;
|
||||
}
|
||||
|
||||
|
||||
function getShowShuffler(show) {
|
||||
if (typeof(show.shuffler) === 'undefined') {
|
||||
if (typeof(show.programs) === 'undefined') {
|
||||
throw Error(show.id + " has no programs?")
|
||||
}
|
||||
|
||||
let sortedPrograms = JSON.parse( JSON.stringify(show.programs) );
|
||||
sortedPrograms.sort((a, b) => {
|
||||
let showA = getShowData(a);
|
||||
let showB = getShowData(b);
|
||||
return showA.order - showB.order;
|
||||
});
|
||||
let n = sortedPrograms.length;
|
||||
|
||||
let splitPrograms = [];
|
||||
let randomPrograms = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
splitPrograms.push( sortedPrograms[i] );
|
||||
randomPrograms.push( {} );
|
||||
}
|
||||
|
||||
|
||||
let showId = getShowData(show.programs[0]).showId;
|
||||
|
||||
let position = show.founder.shuffleOrder;
|
||||
if (typeof(position) === 'undefined') {
|
||||
position = 0;
|
||||
}
|
||||
|
||||
let localRandom = null;
|
||||
|
||||
let initGeneration = (generation) => {
|
||||
let seed = [];
|
||||
for (let i = 0 ; i < show.showId.length; i++) {
|
||||
seed.push( showId.charCodeAt(i) );
|
||||
}
|
||||
seed.push(generation);
|
||||
|
||||
localRandom = new Random( randomJS.MersenneTwister19937.seedWithArray(seed) )
|
||||
|
||||
if (generation == 0) {
|
||||
shuffle( splitPrograms, 0, n , localRandom );
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
randomPrograms[i] = splitPrograms[i];
|
||||
}
|
||||
let a = Math.floor(n / 2);
|
||||
shuffle( randomPrograms, 0, a, localRandom );
|
||||
shuffle( randomPrograms, a, n, localRandom );
|
||||
};
|
||||
initGeneration(0);
|
||||
let generation = Math.floor( position / n );
|
||||
initGeneration( generation );
|
||||
|
||||
show.shuffler = {
|
||||
|
||||
current : () => {
|
||||
let prog = JSON.parse(
|
||||
JSON.stringify(randomPrograms[position % n] )
|
||||
);
|
||||
prog.shuffleOrder = position;
|
||||
return prog;
|
||||
},
|
||||
|
||||
next: () => {
|
||||
position++;
|
||||
if (position % n == 0) {
|
||||
let generation = Math.floor( position / n );
|
||||
initGeneration( generation );
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
return show.shuffler;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getShowOrderer : getShowOrderer,
|
||||
getShowShuffler: getShowShuffler,
|
||||
}
|
||||
6
src/services/throttle.js
Normal file
@ -0,0 +1,6 @@
|
||||
//Adds a slight pause so that long operations
|
||||
module.exports = function() {
|
||||
return new Promise((resolve) => {
|
||||
setImmediate(() => resolve());
|
||||
});
|
||||
}
|
||||
349
src/services/time-slots-service.js
Normal file
@ -0,0 +1,349 @@
|
||||
const constants = require("../constants");
|
||||
|
||||
|
||||
const getShowData = require("./get-show-data")();
|
||||
const random = require('../helperFuncs').random;
|
||||
const throttle = require('./throttle');
|
||||
const orderers = require("./show-orderers");
|
||||
|
||||
const MINUTE = 60*1000;
|
||||
const DAY = 24*60*MINUTE;
|
||||
const LIMIT = 40000;
|
||||
|
||||
|
||||
function getShow(program) {
|
||||
|
||||
let d = getShowData(program);
|
||||
if (! d.hasShow) {
|
||||
return null;
|
||||
} else {
|
||||
d.description = d.showDisplayName;
|
||||
d.id = d.showId;
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
function getProgramId(program) {
|
||||
let s = program.serverKey;
|
||||
if (typeof(s) === 'undefined') {
|
||||
s = 'unknown';
|
||||
}
|
||||
let p = program.key;
|
||||
if (typeof(p) === 'undefined') {
|
||||
p = 'unknown';
|
||||
}
|
||||
return s + "|" + p;
|
||||
}
|
||||
|
||||
function addProgramToShow(show, program) {
|
||||
if ( (show.id == 'flex.') || show.id.startsWith("redirect.") ) {
|
||||
//nothing to do
|
||||
return;
|
||||
}
|
||||
let id = getProgramId(program)
|
||||
if(show.programs[id] !== true) {
|
||||
show.programs.push(program);
|
||||
show.programs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = async( programs, schedule ) => {
|
||||
if (! Array.isArray(programs) ) {
|
||||
return { userError: 'Expected a programs array' };
|
||||
}
|
||||
if (typeof(schedule) === 'undefined') {
|
||||
return { userError: 'Expected a schedule' };
|
||||
}
|
||||
if (typeof(schedule.timeZoneOffset) === 'undefined') {
|
||||
return { userError: 'Expected a time zone offset' };
|
||||
}
|
||||
//verify that the schedule is in the correct format
|
||||
if (! Array.isArray(schedule.slots) ) {
|
||||
return { userError: 'Expected a "slots" array in schedule' };
|
||||
}
|
||||
if (typeof(schedule).period === 'undefined') {
|
||||
schedule.period = DAY;
|
||||
}
|
||||
for (let i = 0; i < schedule.slots.length; i++) {
|
||||
if (typeof(schedule.slots[i].time) === 'undefined') {
|
||||
return { userError: "Each slot should have a time" };
|
||||
}
|
||||
if (typeof(schedule.slots[i].showId) === 'undefined') {
|
||||
return { userError: "Each slot should have a showId" };
|
||||
}
|
||||
if (
|
||||
(schedule.slots[i].time < 0)
|
||||
|| (schedule.slots[i].time >= schedule.period)
|
||||
|| (Math.floor(schedule.slots[i].time) != schedule.slots[i].time)
|
||||
) {
|
||||
return { userError: "Slot times should be a integer number of milliseconds between 0 and period-1, inclusive" };
|
||||
}
|
||||
schedule.slots[i].time = ( schedule.slots[i].time + 10*schedule.period + schedule.timeZoneOffset*MINUTE) % schedule.period;
|
||||
}
|
||||
schedule.slots.sort( (a,b) => {
|
||||
return (a.time - b.time);
|
||||
} );
|
||||
for (let i = 1; i < schedule.slots.length; i++) {
|
||||
if (schedule.slots[i].time == schedule.slots[i-1].time) {
|
||||
return { userError: "Slot times should be unique."};
|
||||
}
|
||||
}
|
||||
if (typeof(schedule.pad) === 'undefined') {
|
||||
return { userError: "Expected schedule.pad" };
|
||||
}
|
||||
|
||||
if (typeof(schedule.lateness) == 'undefined') {
|
||||
return { userError: "schedule.lateness must be defined." };
|
||||
}
|
||||
if (typeof(schedule.maxDays) == 'undefined') {
|
||||
return { userError: "schedule.maxDays must be defined." };
|
||||
}
|
||||
if (typeof(schedule.flexPreference) === 'undefined') {
|
||||
schedule.flexPreference = "distribute";
|
||||
}
|
||||
if (schedule.flexPreference !== "distribute" && schedule.flexPreference !== "end") {
|
||||
return { userError: `Invalid schedule.flexPreference value: "${schedule.flexPreference}"` };
|
||||
}
|
||||
let flexBetween = ( schedule.flexPreference !== "end" );
|
||||
|
||||
// throttle so that the stream is not affected negatively
|
||||
let steps = 0;
|
||||
|
||||
let showsById = {};
|
||||
let shows = [];
|
||||
|
||||
function getNextForSlot(slot, remaining) {
|
||||
//remaining doesn't restrict what next show is picked. It is only used
|
||||
//for shows with flexible length (flex and redirects)
|
||||
if (slot.showId === "flex.") {
|
||||
return {
|
||||
isOffline: true,
|
||||
duration: remaining,
|
||||
}
|
||||
}
|
||||
let show = shows[ showsById[slot.showId] ];
|
||||
|
||||
if (slot.showId.startsWith("redirect.")) {
|
||||
return {
|
||||
isOffline: true,
|
||||
type: "redirect",
|
||||
duration: remaining,
|
||||
channel: show.channel,
|
||||
}
|
||||
} else if (slot.order === 'shuffle') {
|
||||
return orderers.getShowShuffler(show).current();
|
||||
} else if (slot.order === 'next') {
|
||||
return orderers.getShowOrderer(show).current();
|
||||
}
|
||||
}
|
||||
|
||||
function advanceSlot(slot) {
|
||||
if ( (slot.showId === "flex.") || (slot.showId.startsWith("redirect.") ) ) {
|
||||
return;
|
||||
}
|
||||
let show = shows[ showsById[slot.showId] ];
|
||||
if (slot.order === 'shuffle') {
|
||||
return orderers.getShowShuffler(show).next();
|
||||
} else if (slot.order === 'next') {
|
||||
return orderers.getShowOrderer(show).next();
|
||||
}
|
||||
}
|
||||
|
||||
function makePadded(item) {
|
||||
let x = item.duration;
|
||||
let m = x % schedule.pad;
|
||||
let f = 0;
|
||||
if ( (m > constants.SLACK) && (schedule.pad - m > constants.SLACK) ) {
|
||||
f = schedule.pad - m;
|
||||
}
|
||||
return {
|
||||
item: item,
|
||||
pad: f,
|
||||
totalDuration: item.duration + f,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// load the programs
|
||||
for (let i = 0; i < programs.length; i++) {
|
||||
let p = programs[i];
|
||||
let show = getShow(p);
|
||||
if (show != null) {
|
||||
if (typeof(showsById[show.id] ) === 'undefined') {
|
||||
showsById[show.id] = shows.length;
|
||||
shows.push( show );
|
||||
show.founder = p;
|
||||
show.programs = [];
|
||||
} else {
|
||||
show = shows[ showsById[show.id] ];
|
||||
}
|
||||
addProgramToShow( show, p );
|
||||
}
|
||||
}
|
||||
|
||||
let s = schedule.slots;
|
||||
let ts = (new Date() ).getTime();
|
||||
let curr = ts - ts % (schedule.period);
|
||||
let t0 = curr + s[0].time;
|
||||
let p = [];
|
||||
let t = t0;
|
||||
let wantedFinish = t % schedule.period;
|
||||
let hardLimit = t0 + schedule.maxDays * DAY;
|
||||
|
||||
let pushFlex = (d) => {
|
||||
if (d > 0) {
|
||||
t += d;
|
||||
if ( (p.length > 0) && p[p.length-1].isOffline && (p[p.length-1].type != 'redirect') ) {
|
||||
p[p.length-1].duration += d;
|
||||
} else {
|
||||
p.push( {
|
||||
duration: d,
|
||||
isOffline : true,
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pushProgram = (item) => {
|
||||
if ( item.isOffline && (item.type !== 'redirect') ) {
|
||||
pushFlex(item.duration);
|
||||
} else {
|
||||
p.push(item);
|
||||
t += item.duration;
|
||||
}
|
||||
};
|
||||
|
||||
if (ts > t0) {
|
||||
pushFlex( ts - t0 );
|
||||
}
|
||||
while ( (t < hardLimit) && (p.length < LIMIT) ) {
|
||||
await throttle();
|
||||
//ensure t is padded
|
||||
let m = t % schedule.pad;
|
||||
if ( (t % schedule.pad > constants.SLACK) && (schedule.pad - m > constants.SLACK) ) {
|
||||
pushFlex( schedule.pad - m );
|
||||
continue;
|
||||
}
|
||||
|
||||
let dayTime = t % schedule.period;
|
||||
let slot = null;
|
||||
let remaining = null;
|
||||
let late = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
let endTime;
|
||||
if (i == s.length - 1) {
|
||||
endTime = s[0].time + schedule.period;
|
||||
} else {
|
||||
endTime = s[i+1].time;
|
||||
}
|
||||
|
||||
if ((s[i].time <= dayTime) && (dayTime < endTime)) {
|
||||
slot = s[i];
|
||||
remaining = endTime - dayTime;
|
||||
late = dayTime - s[i].time;
|
||||
break;
|
||||
}
|
||||
if ((s[i].time <= dayTime + schedule.period) && (dayTime + schedule.period < endTime)) {
|
||||
slot = s[i];
|
||||
dayTime += schedule.period;
|
||||
remaining = endTime - dayTime;
|
||||
late = dayTime + schedule.period - s[i].time;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot == null) {
|
||||
throw Error("Unexpected. Unable to find slot for time of day " + t + " " + dayTime);
|
||||
}
|
||||
let item = getNextForSlot(slot, remaining);
|
||||
|
||||
if (late >= schedule.lateness + constants.SLACK ) {
|
||||
//it's late.
|
||||
item = {
|
||||
isOffline : true,
|
||||
duration: remaining,
|
||||
}
|
||||
}
|
||||
|
||||
if (item.isOffline) {
|
||||
//flex or redirect. We can just use the whole duration
|
||||
item.duration = remaining;
|
||||
pushProgram(item);
|
||||
continue;
|
||||
}
|
||||
if (item.duration > remaining) {
|
||||
// Slide
|
||||
pushProgram(item);
|
||||
advanceSlot(slot);
|
||||
continue;
|
||||
}
|
||||
|
||||
let padded = makePadded(item);
|
||||
let total = padded.totalDuration;
|
||||
advanceSlot(slot);
|
||||
let pads = [ padded ];
|
||||
|
||||
while(true) {
|
||||
let item2 = getNextForSlot(slot, remaining);
|
||||
if (total + item2.duration > remaining) {
|
||||
break;
|
||||
}
|
||||
let padded2 = makePadded(item2);
|
||||
pads.push(padded2);
|
||||
advanceSlot(slot);
|
||||
total += padded2.totalDuration;
|
||||
}
|
||||
let rem = Math.max(0, remaining - total);
|
||||
|
||||
if (flexBetween) {
|
||||
let div = Math.floor(rem / schedule.pad );
|
||||
let mod = rem % schedule.pad;
|
||||
// add mod to the latest item
|
||||
pads[ pads.length - 1].pad += mod;
|
||||
pads[ pads.length - 1].totalDuration += mod;
|
||||
|
||||
let sortedPads = pads.map( (p, $index) => {
|
||||
return {
|
||||
pad: p.pad,
|
||||
index : $index,
|
||||
}
|
||||
});
|
||||
sortedPads.sort( (a,b) => { return a.pad - b.pad; } );
|
||||
for (let i = 0; i < pads.length; i++) {
|
||||
let q = Math.floor( div / pads.length );
|
||||
if (i < div % pads.length) {
|
||||
q++;
|
||||
}
|
||||
let j = sortedPads[i].index;
|
||||
pads[j].pad += q * schedule.pad;
|
||||
}
|
||||
} else {
|
||||
//also add div to the latest item
|
||||
pads[ pads.length - 1].pad += rem;
|
||||
pads[ pads.length - 1].totalDuration += rem;
|
||||
}
|
||||
// now unroll them all
|
||||
for (let i = 0; i < pads.length; i++) {
|
||||
pushProgram( pads[i].item );
|
||||
pushFlex( pads[i].pad );
|
||||
}
|
||||
}
|
||||
while ( (t > hardLimit) || (p.length >= LIMIT) ) {
|
||||
t -= p.pop().duration;
|
||||
}
|
||||
let m = (t - t0) % schedule.period;
|
||||
if (m > 0) {
|
||||
//ensure the schedule is a multiple of period
|
||||
pushFlex( schedule.period - m);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
programs: p,
|
||||
startTime: (new Date(t0)).toISOString(),
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
593
src/services/tv-guide-service.js
Normal file
135
src/svg/dizquetv.svg
Normal file
@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="200"
|
||||
height="200"
|
||||
viewBox="0 0 52.9168 52.916668"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||
sodipodi:docname="dizquetv.svg"
|
||||
inkscape:export-filename="/home/vx/dev/pseudotv/resources/dizquetv.png"
|
||||
inkscape:export-xdpi="240"
|
||||
inkscape:export-ydpi="240">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="74.610162"
|
||||
inkscape:cy="39.873047"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1056"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="24"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Capa 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-244.08278)">
|
||||
<rect
|
||||
style="opacity:1;fill:#3f3f3f;fill-opacity:0.86666667;stroke:none;stroke-width:1.46501946;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect4712"
|
||||
width="52.206699"
|
||||
height="35.866219"
|
||||
x="-1.3992016"
|
||||
y="254.87126"
|
||||
transform="matrix(0.99990505,-0.01378015,0.00643904,0.99997927,0,0)" />
|
||||
<g
|
||||
id="g4581"
|
||||
style="fill:#080808;fill-opacity:1;stroke-width:0.82573813"
|
||||
transform="matrix(1.2119871,0,0,1.2100891,-82.577875,-32.337926)">
|
||||
<rect
|
||||
transform="rotate(-0.94645665)"
|
||||
y="239.28041"
|
||||
x="65.156158"
|
||||
height="27.75024"
|
||||
width="41.471352"
|
||||
id="rect4524"
|
||||
style="opacity:1;fill:#080808;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
</g>
|
||||
<rect
|
||||
style="opacity:1;fill:#9cbc28;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect4518"
|
||||
width="10.338528"
|
||||
height="23.042738"
|
||||
x="8.7463942"
|
||||
y="260.34518"
|
||||
transform="matrix(0.99995865,0.00909414,-0.00926779,0.99995705,0,0)" />
|
||||
<ellipse
|
||||
style="opacity:1;fill:#a1a1a1;fill-opacity:0.86792453;stroke:none;stroke-width:1.46499991;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="path4568"
|
||||
cx="44.118061"
|
||||
cy="263.32062"
|
||||
rx="2.4216392"
|
||||
ry="2.3988426" />
|
||||
<ellipse
|
||||
cy="275.02563"
|
||||
cx="44.765343"
|
||||
id="circle4570"
|
||||
style="opacity:1;fill:#a1a1a1;fill-opacity:0.86792453;stroke:none;stroke-width:1.46499991;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
rx="2.4216392"
|
||||
ry="2.3988426" />
|
||||
<g
|
||||
id="g1705">
|
||||
<rect
|
||||
transform="matrix(0.99967585,0.02545985,-0.02594573,0.99966335,0,0)"
|
||||
style="opacity:1;fill:#289bbc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect4520"
|
||||
width="10.338468"
|
||||
height="23.042864"
|
||||
x="23.424755"
|
||||
y="259.99872" />
|
||||
</g>
|
||||
<rect
|
||||
transform="matrix(0.99837418,-0.05699994,0.05808481,0.99831165,0,0)"
|
||||
y="261.80557"
|
||||
x="10.394932"
|
||||
height="23.043449"
|
||||
width="10.33821"
|
||||
id="rect4522"
|
||||
style="opacity:1;fill:#bc289b;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:2.03992438px;line-height:125%;font-family:'Liberation Serif';-inkscape-font-specification:'Liberation Serif';letter-spacing:0px;word-spacing:0px;fill:#e6e6e6;fill-opacity:1;stroke:none;stroke-width:0.264584px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="16.934374"
|
||||
y="288.46368"
|
||||
id="text1730"
|
||||
transform="rotate(-1.2296789)"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan1728"
|
||||
x="16.934374"
|
||||
y="288.46368"
|
||||
style="fill:#e6e6e6;fill-opacity:1;stroke-width:0.264584px">dizqueTV</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 4.2 KiB |
115
src/svg/generic-music-screen.svg
Normal file
@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="1920"
|
||||
height="1080"
|
||||
viewBox="0 0 507.99999 285.75001"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||
sodipodi:docname="generic-music-screen.svg"
|
||||
inkscape:export-filename="/home/vx/dev/pseudotv/resources/generic-music-screen.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="4.4585776"
|
||||
inkscape:cx="925.75604"
|
||||
inkscape:cy="448.17449"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1056"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="24"
|
||||
inkscape:window-maximized="1"
|
||||
units="px" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Capa 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-11.249983)">
|
||||
<rect
|
||||
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.20000029;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect836"
|
||||
width="508"
|
||||
height="285.75"
|
||||
x="0"
|
||||
y="11.249983" />
|
||||
<g
|
||||
id="g6050"
|
||||
transform="translate(-8.4960767,30.053154)">
|
||||
<rect
|
||||
transform="rotate(0.52601418)"
|
||||
y="85.000603"
|
||||
x="214.56714"
|
||||
height="73.832573"
|
||||
width="32.814484"
|
||||
id="rect4518"
|
||||
style="opacity:1;fill:#9cbc28;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
<rect
|
||||
transform="rotate(1.4727575)"
|
||||
style="opacity:1;fill:#289bbc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect4520"
|
||||
width="32.81448"
|
||||
height="73.832573"
|
||||
x="248.74632"
|
||||
y="80.901688" />
|
||||
<rect
|
||||
transform="rotate(-3.2986121)"
|
||||
y="103.78287"
|
||||
x="269.35843"
|
||||
height="73.832588"
|
||||
width="32.814476"
|
||||
id="rect4522"
|
||||
style="opacity:1;fill:#bc289b;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
</g>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-weight:normal;font-size:76.95687866px;line-height:125%;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="147.14322"
|
||||
y="234.94209"
|
||||
id="text838"
|
||||
transform="scale(1.3642872,0.73298349)"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan836"
|
||||
x="147.14322"
|
||||
y="234.94209"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:KacstPoster;-inkscape-font-specification:KacstPoster;stroke-width:0.26458335px">♪</tspan></text>
|
||||
<ellipse
|
||||
style="opacity:1;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0.52916664;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="path840"
|
||||
cx="240.60326"
|
||||
cy="169.0907"
|
||||
rx="15.090722"
|
||||
ry="15.089045" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 3.1 KiB |
107
src/svg/loading-screen.svg
Normal file
@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="1920"
|
||||
height="1080"
|
||||
viewBox="0 0 507.99999 285.75001"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||
sodipodi:docname="loading-screen.svg"
|
||||
inkscape:export-filename="/home/vx/dev/pseudotv/resources/loading-screen.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.37433674"
|
||||
inkscape:cx="1004.7641"
|
||||
inkscape:cy="545.11626"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1056"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="24"
|
||||
inkscape:window-maximized="1"
|
||||
units="px" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Capa 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-11.249983)">
|
||||
<rect
|
||||
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.20000029;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect836"
|
||||
width="508"
|
||||
height="285.75"
|
||||
x="0"
|
||||
y="11.249983" />
|
||||
<g
|
||||
id="g6050"
|
||||
transform="translate(-8.4960767,30.053154)">
|
||||
<rect
|
||||
transform="rotate(0.52601418)"
|
||||
y="85.000603"
|
||||
x="214.56714"
|
||||
height="73.832573"
|
||||
width="32.814484"
|
||||
id="rect4518"
|
||||
style="opacity:1;fill:#9cbc28;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
<rect
|
||||
transform="rotate(1.4727575)"
|
||||
style="opacity:1;fill:#289bbc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect4520"
|
||||
width="32.81448"
|
||||
height="73.832573"
|
||||
x="248.74632"
|
||||
y="80.901688" />
|
||||
<rect
|
||||
transform="rotate(-3.2986121)"
|
||||
y="103.78287"
|
||||
x="269.35843"
|
||||
height="73.832588"
|
||||
width="32.814476"
|
||||
id="rect4522"
|
||||
style="opacity:1;fill:#bc289b;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
</g>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-weight:normal;font-size:10.58333302px;line-height:125%;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="228.70648"
|
||||
y="210.99644"
|
||||
id="text939"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan937"
|
||||
x="228.70648"
|
||||
y="210.99644"
|
||||
style="fill:#f9f9f9;stroke-width:0.26458332px">Loading...</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
64
src/throttler.js
Normal file
@ -0,0 +1,64 @@
|
||||
let constants = require('./constants');
|
||||
|
||||
let cache = {}
|
||||
|
||||
|
||||
function equalItems(a, b) {
|
||||
if ( (typeof(a) === 'undefined') || a.isOffline || b.isOffline ) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(a.type === "loading") || (a.type === "interlude")
|
||||
|| (b.type === "loading") || (b.type === "interlude")
|
||||
) {
|
||||
return (a.type === b.type);
|
||||
}
|
||||
if (a.type != b.type) {
|
||||
return false;
|
||||
}
|
||||
if (a.type !== "program") {
|
||||
console.log("no idea how to compare this: " + JSON.stringify(a).slice(0,100) );
|
||||
console.log(" with this: " + JSON.stringify(b).slice(0,100) );
|
||||
}
|
||||
return a.title === b.title;
|
||||
|
||||
}
|
||||
|
||||
|
||||
function wereThereTooManyAttempts(sessionId, lineupItem) {
|
||||
|
||||
let t1 = (new Date()).getTime();
|
||||
|
||||
let previous = cache[sessionId];
|
||||
let result = false;
|
||||
|
||||
if (typeof(previous) === 'undefined') {
|
||||
previous = cache[sessionId] = {
|
||||
t0: t1 - constants.TOO_FREQUENT * 5,
|
||||
lineupItem: null,
|
||||
};
|
||||
} else if (t1 - previous.t0 < constants.TOO_FREQUENT) {
|
||||
//certainly too frequent
|
||||
result = equalItems( previous.lineupItem, lineupItem );
|
||||
}
|
||||
|
||||
cache[sessionId] = {
|
||||
t0: t1,
|
||||
lineupItem : lineupItem,
|
||||
};
|
||||
|
||||
setTimeout( () => {
|
||||
if (
|
||||
(typeof(cache[sessionId]) !== 'undefined')
|
||||
&&
|
||||
(cache[sessionId].t0 === t1)
|
||||
) {
|
||||
delete cache[sessionId];
|
||||
}
|
||||
}, constants.TOO_FREQUENT * 5 );
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
module.exports = wereThereTooManyAttempts;
|
||||
567
src/video.js
163
src/xmltv.js
@ -1,38 +1,45 @@
|
||||
const XMLWriter = require('xml-writer')
|
||||
const fs = require('fs')
|
||||
const helperFuncs = require('./helperFuncs')
|
||||
|
||||
module.exports = { WriteXMLTV: WriteXMLTV }
|
||||
module.exports = { WriteXMLTV: WriteXMLTV, shutdown: shutdown }
|
||||
|
||||
function WriteXMLTV(channels, xmlSettings) {
|
||||
let isShutdown = false;
|
||||
let isWorking = false;
|
||||
|
||||
async function WriteXMLTV(json, xmlSettings, throttle, cacheImageService) {
|
||||
if (isShutdown) {
|
||||
return;
|
||||
}
|
||||
if (isWorking) {
|
||||
console.log("Concurrent xmltv write attempt detected, skipping");
|
||||
return;
|
||||
}
|
||||
isWorking = true;
|
||||
try {
|
||||
await writePromise(json, xmlSettings, throttle, cacheImageService);
|
||||
} catch (err) {
|
||||
console.error("Error writing xmltv", err);
|
||||
}
|
||||
isWorking = false;
|
||||
}
|
||||
|
||||
function writePromise(json, xmlSettings, throttle, cacheImageService) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let date = new Date()
|
||||
let ws = fs.createWriteStream(xmlSettings.file)
|
||||
let xw = new XMLWriter(true, (str, enc) => ws.write(str, enc))
|
||||
ws.on('close', () => { resolve() })
|
||||
ws.on('error', (err) => { reject(err) })
|
||||
_writeDocStart(xw)
|
||||
async function middle() {
|
||||
if (channels.length === 0) { // Write Dummy PseudoTV Channel if no channel exists
|
||||
_writeChannels(xw, [{ number: 1, name: "PseudoTV", icon: "https://raw.githubusercontent.com/DEFENDORe/pseudotv/master/resources/pseudotv.png" }])
|
||||
let program = {
|
||||
program: {
|
||||
type: 'movie',
|
||||
title: 'No Channels Configured',
|
||||
summary: 'Configure your channels using the PseudoTV Web UI.'
|
||||
},
|
||||
channel: '1',
|
||||
start: date,
|
||||
stop: new Date(date.valueOf() + xmlSettings.cache * 60 * 60 * 1000)
|
||||
}
|
||||
await _writeProgramme(xw, program)
|
||||
} else {
|
||||
_writeChannels(xw, channels)
|
||||
for (let i = 0; i < channels.length; i++) {
|
||||
await _writePrograms(xw, channels[i], date, xmlSettings.cache)
|
||||
async function middle() {
|
||||
let channelNumbers = [];
|
||||
Object.keys(json).forEach( (key, index) => channelNumbers.push(key) );
|
||||
let channels = channelNumbers.map( (number) => json[number].channel );
|
||||
_writeChannels( xw, channels );
|
||||
for (let i = 0; i < channelNumbers.length; i++) {
|
||||
let number = channelNumbers[i];
|
||||
await _writePrograms(xw, json[number].channel, json[number].programs, throttle, xmlSettings, cacheImageService);
|
||||
}
|
||||
}
|
||||
}
|
||||
middle().then( () => {
|
||||
_writeDocEnd(xw, ws)
|
||||
}).catch( (err) => {
|
||||
@ -44,7 +51,7 @@ function WriteXMLTV(channels, xmlSettings) {
|
||||
function _writeDocStart(xw) {
|
||||
xw.startDocument()
|
||||
xw.startElement('tv')
|
||||
xw.writeAttribute('generator-info-name', 'psuedotv-plex')
|
||||
xw.writeAttribute('generator-info-name', 'dizquetv')
|
||||
}
|
||||
function _writeDocEnd(xw, ws) {
|
||||
xw.endElement()
|
||||
@ -68,87 +75,99 @@ function _writeChannels(xw, channels) {
|
||||
}
|
||||
}
|
||||
|
||||
async function _writePrograms(xw, channel, date, cache) {
|
||||
let prog = helperFuncs.getCurrentProgramAndTimeElapsed(date, channel)
|
||||
let cutoff = new Date((date.valueOf() - prog.timeElapsed) + (cache * 60 * 60 * 1000))
|
||||
let temp = new Date(date.valueOf() - prog.timeElapsed)
|
||||
if (channel.programs.length === 0)
|
||||
return
|
||||
let i = prog.programIndex
|
||||
for (; temp < cutoff;) {
|
||||
await _throttle(); //let's not block for this process
|
||||
let program = {
|
||||
program: channel.programs[i],
|
||||
channel: channel.number,
|
||||
start: new Date(temp.valueOf()),
|
||||
stop: new Date(temp.valueOf() + channel.programs[i].duration)
|
||||
async function _writePrograms(xw, channel, programs, throttle, xmlSettings, cacheImageService) {
|
||||
for (let i = 0; i < programs.length; i++) {
|
||||
if (! isShutdown) {
|
||||
await throttle();
|
||||
}
|
||||
_writeProgramme(xw, program)
|
||||
temp.setMilliseconds(temp.getMilliseconds() + channel.programs[i].duration)
|
||||
i++
|
||||
if (i >= channel.programs.length)
|
||||
i = 0
|
||||
await _writeProgramme(channel, programs[i], xw, xmlSettings, cacheImageService);
|
||||
}
|
||||
}
|
||||
|
||||
async function _writeProgramme(xw, program) {
|
||||
if (program.program.isOffline === true) {
|
||||
//do not write anything for the offline period
|
||||
return;
|
||||
}
|
||||
async function _writeProgramme(channel, program, xw, xmlSettings, cacheImageService) {
|
||||
// Programme
|
||||
xw.startElement('programme')
|
||||
xw.writeAttribute('start', _createXMLTVDate(program.start))
|
||||
xw.writeAttribute('stop', _createXMLTVDate(program.stop))
|
||||
xw.writeAttribute('channel', program.channel)
|
||||
xw.writeAttribute('stop', _createXMLTVDate(program.stop ))
|
||||
xw.writeAttribute('channel', channel.number)
|
||||
// Title
|
||||
xw.startElement('title')
|
||||
xw.writeAttribute('lang', 'en')
|
||||
xw.text(program.title);
|
||||
xw.endElement();
|
||||
xw.writeRaw('\n <previously-shown/>')
|
||||
|
||||
if (program.program.type === 'episode') {
|
||||
xw.text(program.program.showTitle)
|
||||
xw.endElement()
|
||||
xw.writeRaw('\n <previously-shown/>')
|
||||
// Sub-Title
|
||||
//sub-title
|
||||
// TODO: Add support for track data (artist, album) here
|
||||
if ( typeof(program.sub) !== 'undefined') {
|
||||
xw.startElement('sub-title')
|
||||
xw.writeAttribute('lang', 'en')
|
||||
xw.text(program.program.title)
|
||||
xw.text(program.sub.title)
|
||||
xw.endElement()
|
||||
// Episode-Number
|
||||
|
||||
xw.startElement('episode-num')
|
||||
xw.writeAttribute('system', 'onscreen')
|
||||
xw.text( "S" + (program.sub.season) + ' E' + (program.sub.episode) )
|
||||
xw.endElement()
|
||||
|
||||
xw.startElement('episode-num')
|
||||
xw.writeAttribute('system', 'xmltv_ns')
|
||||
xw.text((program.program.season - 1) + ' . ' + (program.program.episode - 1) + ' . 0/1')
|
||||
xw.endElement()
|
||||
} else {
|
||||
xw.text(program.program.title)
|
||||
xw.text((program.sub.season - 1) + '.' + (program.sub.episode - 1) + '.0/1')
|
||||
xw.endElement()
|
||||
|
||||
}
|
||||
// Icon
|
||||
if (typeof program.program.icon !== 'undefined') {
|
||||
xw.startElement('icon')
|
||||
xw.writeAttribute('src', program.program.icon)
|
||||
xw.endElement()
|
||||
if (typeof program.icon !== 'undefined') {
|
||||
xw.startElement('icon');
|
||||
let icon = program.icon;
|
||||
if (xmlSettings.enableImageCache === true) {
|
||||
const imgUrl = cacheImageService.registerImageOnDatabase(icon);
|
||||
icon = `{{host}}/cache/images/${imgUrl}`;
|
||||
}
|
||||
xw.writeAttribute('src', icon);
|
||||
xw.endElement();
|
||||
}
|
||||
// Desc
|
||||
xw.startElement('desc')
|
||||
xw.writeAttribute('lang', 'en')
|
||||
xw.text(program.program.summary)
|
||||
if ( (typeof(program.summary) !== 'undefined') && (program.summary.length > 0) ) {
|
||||
xw.text(program.summary)
|
||||
} else {
|
||||
xw.text(channel.name)
|
||||
}
|
||||
xw.endElement()
|
||||
// Rating
|
||||
if (typeof program.program.rating !== 'undefined') {
|
||||
if ( (program.rating != null) && (typeof program.rating !== 'undefined') ) {
|
||||
xw.startElement('rating')
|
||||
xw.writeAttribute('system', 'MPAA')
|
||||
xw.writeElement('value', program.program.rating)
|
||||
xw.writeElement('value', program.rating)
|
||||
xw.endElement()
|
||||
}
|
||||
// End of Programme
|
||||
xw.endElement()
|
||||
}
|
||||
function _createXMLTVDate(d) {
|
||||
return d.toISOString().substring(0,19).replace(/[-T:]/g,"") + " +0000";
|
||||
return d.substring(0,19).replace(/[-T:]/g,"") + " +0000";
|
||||
}
|
||||
function _throttle() {
|
||||
function wait(x) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
setTimeout(resolve, x);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
isShutdown = true;
|
||||
console.log("Shutting down xmltv writer.");
|
||||
if (isWorking) {
|
||||
let s = "Wait for xmltv writer...";
|
||||
while (isWorking) {
|
||||
console.log(s);
|
||||
await wait(100);
|
||||
s = "Still waiting for xmltv writer...";
|
||||
}
|
||||
console.log("Write finished.");
|
||||
} else {
|
||||
console.log("xmltv writer had no pending jobs.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
73
web/app.js
@ -3,11 +3,40 @@ require('angular-router-browserify')(angular)
|
||||
require('./ext/lazyload')(angular)
|
||||
require('./ext/dragdrop')
|
||||
require('./ext/angularjs-scroll-glue')
|
||||
require('angular-vs-repeat');
|
||||
require('angular-sanitize');
|
||||
const i18next = require('i18next');
|
||||
const i18nextHttpBackend = require('i18next-http-backend');
|
||||
window.i18next = i18next;
|
||||
|
||||
var app = angular.module('myApp', ['ngRoute', 'angularLazyImg', 'dndLists', 'luegg.directives'])
|
||||
window.i18next.use(i18nextHttpBackend);
|
||||
|
||||
window.i18next.init({
|
||||
// debug: true,
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
preload: ['en'],
|
||||
ns: [ 'main' ],
|
||||
defaultNS: [ 'main' ],
|
||||
initImmediate: false,
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json'
|
||||
},
|
||||
useCookie: false,
|
||||
useLocalStorage: false,
|
||||
}, function (err, t) {
|
||||
console.log('resources loaded');
|
||||
});
|
||||
|
||||
require('ng-i18next');
|
||||
|
||||
var app = angular.module('myApp', ['ngRoute', 'vs-repeat', 'angularLazyImg', 'dndLists', 'luegg.directives', 'jm.i18next'])
|
||||
|
||||
app.service('plex', require('./services/plex'))
|
||||
app.service('pseudotv', require('./services/pseudotv'))
|
||||
app.service('dizquetv', require('./services/dizquetv'))
|
||||
app.service('resolutionOptions', require('./services/resolution-options'))
|
||||
app.service('getShowData', require('./services/get-show-data'))
|
||||
app.service('commonProgramTools', require('./services/common-program-tools'))
|
||||
|
||||
app.directive('plexSettings', require('./directives/plex-settings'))
|
||||
app.directive('ffmpegSettings', require('./directives/ffmpeg-settings'))
|
||||
@ -15,12 +44,28 @@ app.directive('xmltvSettings', require('./directives/xmltv-settings'))
|
||||
app.directive('hdhrSettings', require('./directives/hdhr-settings'))
|
||||
app.directive('plexLibrary', require('./directives/plex-library'))
|
||||
app.directive('programConfig', require('./directives/program-config'))
|
||||
app.directive('offlineConfig', require('./directives/offline-config'))
|
||||
app.directive('flexConfig', require('./directives/flex-config'))
|
||||
app.directive('timeSlotsTimeEditor', require('./directives/time-slots-time-editor'))
|
||||
app.directive('toastNotifications', require('./directives/toast-notifications'))
|
||||
app.directive('fillerConfig', require('./directives/filler-config'))
|
||||
app.directive('showConfig', require('./directives/show-config'))
|
||||
app.directive('deleteFiller', require('./directives/delete-filler'))
|
||||
app.directive('frequencyTweak', require('./directives/frequency-tweak'))
|
||||
app.directive('removeShows', require('./directives/remove-shows'))
|
||||
app.directive('channelRedirect', require('./directives/channel-redirect'))
|
||||
app.directive('plexServerEdit', require('./directives/plex-server-edit'))
|
||||
app.directive('channelConfig', require('./directives/channel-config'))
|
||||
app.directive('timeSlotsScheduleEditor', require('./directives/time-slots-schedule-editor'))
|
||||
app.directive('randomSlotsScheduleEditor', require('./directives/random-slots-schedule-editor'))
|
||||
|
||||
app.controller('settingsCtrl', require('./controllers/settings'))
|
||||
app.controller('channelsCtrl', require('./controllers/channels'))
|
||||
app.controller('versionCtrl', require('./controllers/version'))
|
||||
app.controller('libraryCtrl', require('./controllers/library'))
|
||||
app.controller('guideCtrl', require('./controllers/guide'))
|
||||
app.controller('playerCtrl', require('./controllers/player'))
|
||||
app.controller('fillerCtrl', require('./controllers/filler'))
|
||||
app.controller('customShowsCtrl', require('./controllers/custom-shows'))
|
||||
|
||||
app.config(function ($routeProvider) {
|
||||
$routeProvider
|
||||
@ -32,11 +77,31 @@ app.config(function ($routeProvider) {
|
||||
templateUrl: "views/channels.html",
|
||||
controller: 'channelsCtrl'
|
||||
})
|
||||
.when("/filler", {
|
||||
templateUrl: "views/filler.html",
|
||||
controller: 'fillerCtrl'
|
||||
})
|
||||
.when("/custom-shows", {
|
||||
templateUrl: "views/custom-shows.html",
|
||||
controller: 'customShowsCtrl'
|
||||
})
|
||||
.when("/library", {
|
||||
templateUrl: "views/library.html",
|
||||
controller: 'libraryCtrl'
|
||||
})
|
||||
.when("/guide", {
|
||||
templateUrl: "views/guide.html",
|
||||
controller: 'guideCtrl'
|
||||
})
|
||||
.when("/player", {
|
||||
templateUrl: "views/player.html",
|
||||
controller: 'playerCtrl'
|
||||
})
|
||||
.when("/version", {
|
||||
templateUrl: "views/version.html",
|
||||
controller: 'versionCtrl'
|
||||
})
|
||||
.otherwise({
|
||||
redirectTo: "channels"
|
||||
redirectTo: "guide"
|
||||
})
|
||||
})
|
||||
@ -1,43 +1,98 @@
|
||||
module.exports = function ($scope, pseudotv) {
|
||||
module.exports = function ($scope, dizquetv) {
|
||||
$scope.channels = []
|
||||
$scope.showChannelConfig = false
|
||||
$scope.selectedChannel = null
|
||||
$scope.selectedChannelIndex = -1
|
||||
|
||||
pseudotv.getChannels().then((channels) => {
|
||||
$scope.channels = channels
|
||||
})
|
||||
$scope.removeChannel = (channel) => {
|
||||
if (confirm("Are you sure to delete channel: " + channel.name + "?")) {
|
||||
pseudotv.removeChannel(channel).then((channels) => {
|
||||
$scope.channels = channels
|
||||
})
|
||||
$scope.refreshChannels = async () => {
|
||||
$scope.channels = [ { number: 1, pending: true} ]
|
||||
let channelNumbers = await dizquetv.getChannelNumbers();
|
||||
$scope.channels = channelNumbers.map( (x) => {
|
||||
return {
|
||||
number: x,
|
||||
pending: true,
|
||||
}
|
||||
});
|
||||
$scope.$apply();
|
||||
$scope.queryChannels();
|
||||
}
|
||||
$scope.refreshChannels();
|
||||
|
||||
$scope.queryChannels = () => {
|
||||
for (let i = 0; i < $scope.channels.length; i++) {
|
||||
$scope.queryChannel(i, $scope.channels[i] );
|
||||
}
|
||||
}
|
||||
$scope.onChannelConfigDone = (channel) => {
|
||||
|
||||
$scope.queryChannel = async (index, channel) => {
|
||||
let ch = await dizquetv.getChannelDescription(channel.number);
|
||||
ch.pending = false;
|
||||
$scope.channels[index] = ch;
|
||||
$scope.$apply();
|
||||
}
|
||||
|
||||
$scope.removeChannel = async ($index, channel) => {
|
||||
if (confirm("Are you sure to delete channel: " + channel.name + "?")) {
|
||||
$scope.channels[$index].pending = true;
|
||||
await dizquetv.removeChannel(channel);
|
||||
$scope.refreshChannels();
|
||||
}
|
||||
}
|
||||
$scope.onChannelConfigDone = async (channel) => {
|
||||
if ($scope.selectedChannelIndex != -1) {
|
||||
$scope.channels[ $scope.selectedChannelIndex ].pending = false;
|
||||
}
|
||||
if (typeof channel !== 'undefined') {
|
||||
if ($scope.selectedChannelIndex == -1) { // add new channel
|
||||
pseudotv.addChannel(channel).then((channels) => {
|
||||
$scope.channels = channels
|
||||
})
|
||||
await dizquetv.addChannel(channel);
|
||||
$scope.showChannelConfig = false
|
||||
$scope.refreshChannels();
|
||||
|
||||
} else if (
|
||||
(typeof($scope.originalChannelNumber) !== 'undefined')
|
||||
&& ($scope.originalChannelNumber != channel.number)
|
||||
) {
|
||||
//update + change channel number.
|
||||
$scope.channels[ $scope.selectedChannelIndex ].pending = true;
|
||||
await dizquetv.updateChannel(channel),
|
||||
await dizquetv.removeChannel( { number: $scope.originalChannelNumber } )
|
||||
$scope.showChannelConfig = false
|
||||
$scope.$apply();
|
||||
$scope.refreshChannels();
|
||||
} else { // update existing channel
|
||||
pseudotv.updateChannel(channel).then((channels) => {
|
||||
$scope.channels = channels
|
||||
})
|
||||
$scope.channels[ $scope.selectedChannelIndex ].pending = true;
|
||||
await dizquetv.updateChannel(channel);
|
||||
$scope.showChannelConfig = false
|
||||
$scope.$apply();
|
||||
$scope.refreshChannels();
|
||||
}
|
||||
} else {
|
||||
$scope.showChannelConfig = false
|
||||
}
|
||||
$scope.showChannelConfig = false
|
||||
|
||||
|
||||
}
|
||||
$scope.selectChannel = (index) => {
|
||||
if (index === -1) {
|
||||
$scope.selectChannel = async (index) => {
|
||||
if ( (index === -1) || $scope.channels[index].pending ) {
|
||||
$scope.originalChannelNumber = undefined;
|
||||
$scope.selectedChannel = null
|
||||
$scope.selectedChannelIndex = -1
|
||||
$scope.showChannelConfig = true
|
||||
} else {
|
||||
let newObj = JSON.parse(angular.toJson($scope.channels[index]))
|
||||
$scope.channels[index].pending = true;
|
||||
let p = await Promise.all([
|
||||
dizquetv.getChannelProgramless($scope.channels[index].number),
|
||||
dizquetv.getChannelPrograms($scope.channels[index].number),
|
||||
]);
|
||||
let ch = p[0];
|
||||
ch.programs = p[1];
|
||||
let newObj = ch;
|
||||
newObj.startTime = new Date(newObj.startTime)
|
||||
$scope.originalChannelNumber = newObj.number;
|
||||
$scope.selectedChannel = newObj
|
||||
$scope.selectedChannelIndex = index
|
||||
$scope.showChannelConfig = true
|
||||
$scope.$apply();
|
||||
}
|
||||
$scope.showChannelConfig = true
|
||||
}
|
||||
}
|
||||
94
web/controllers/custom-shows.js
Normal file
@ -0,0 +1,94 @@
|
||||
module.exports = function ($scope, $timeout, dizquetv) {
|
||||
$scope.showss = []
|
||||
$scope.showShowConfig = false
|
||||
$scope.selectedShow = null
|
||||
$scope.selectedShowIndex = -1
|
||||
|
||||
$scope.refreshShow = async () => {
|
||||
$scope.shows = [ { id: '?', pending: true} ]
|
||||
$timeout();
|
||||
let shows = await dizquetv.getAllShowsInfo();
|
||||
shows.sort( (a,b) => {
|
||||
return a.name > b.name;
|
||||
} );
|
||||
|
||||
$scope.shows = shows;
|
||||
$timeout();
|
||||
}
|
||||
$scope.refreshShow();
|
||||
|
||||
|
||||
|
||||
let feedToShowConfig = () => {};
|
||||
let feedToDeleteShow = feedToShowConfig;
|
||||
|
||||
$scope.registerShowConfig = (feed) => {
|
||||
feedToShowConfig = feed;
|
||||
}
|
||||
|
||||
$scope.registerDeleteShow = (feed) => {
|
||||
feedToDeleteShow = feed;
|
||||
}
|
||||
|
||||
$scope.queryChannel = async (index, channel) => {
|
||||
let ch = await dizquetv.getChannelDescription(channel.number);
|
||||
ch.pending = false;
|
||||
$scope.shows[index] = ch;
|
||||
$scope.$apply();
|
||||
}
|
||||
|
||||
$scope.onShowConfigDone = async (show) => {
|
||||
if ($scope.selectedChannelIndex != -1) {
|
||||
$scope.shows[ $scope.selectedChannelIndex ].pending = false;
|
||||
}
|
||||
if (typeof show !== 'undefined') {
|
||||
// not canceled
|
||||
if ($scope.selectedChannelIndex == -1) { // add new channel
|
||||
await dizquetv.createShow(show);
|
||||
} else {
|
||||
$scope.shows[ $scope.selectedChannelIndex ].pending = true;
|
||||
await dizquetv.updateShow(show.id, show);
|
||||
}
|
||||
await $scope.refreshShow();
|
||||
}
|
||||
}
|
||||
$scope.selectShow = async (index) => {
|
||||
try {
|
||||
if ( (index != -1) && $scope.shows[index].pending) {
|
||||
return;
|
||||
}
|
||||
$scope.selectedChannelIndex = index;
|
||||
if (index === -1) {
|
||||
feedToShowConfig();
|
||||
} else {
|
||||
$scope.shows[index].pending = true;
|
||||
let f = await dizquetv.getShow($scope.shows[index].id);
|
||||
feedToShowConfig(f);
|
||||
$timeout();
|
||||
}
|
||||
} catch( err ) {
|
||||
console.error("Could not fetch show.", err);
|
||||
}
|
||||
}
|
||||
|
||||
$scope.deleteShow = async (index) => {
|
||||
try {
|
||||
if ( $scope.shows[index].pending) {
|
||||
return;
|
||||
}
|
||||
let show = $scope.shows[index];
|
||||
if (confirm("Are you sure to delete show: " + show.name + "? This will NOT delete the show's programs from channels that are using.")) {
|
||||
show.pending = true;
|
||||
await dizquetv.deleteShow(show.id);
|
||||
$timeout();
|
||||
await $scope.refreshShow();
|
||||
$timeout();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Could not delete show.", err);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
108
web/controllers/filler.js
Normal file
@ -0,0 +1,108 @@
|
||||
module.exports = function ($scope, $timeout, dizquetv) {
|
||||
$scope.fillers = []
|
||||
$scope.showFillerConfig = false
|
||||
$scope.selectedFiller = null
|
||||
$scope.selectedFillerIndex = -1
|
||||
|
||||
$scope.refreshFiller = async () => {
|
||||
$scope.fillers = [ { id: '?', pending: true} ]
|
||||
$timeout();
|
||||
let fillers = await dizquetv.getAllFillersInfo();
|
||||
fillers.sort( (a,b) => {
|
||||
return a.name > b.name;
|
||||
} );
|
||||
$scope.fillers = fillers;
|
||||
$timeout();
|
||||
}
|
||||
$scope.refreshFiller();
|
||||
|
||||
let feedToFillerConfig = () => {};
|
||||
let feedToDeleteFiller = feedToFillerConfig;
|
||||
|
||||
$scope.registerFillerConfig = (feed) => {
|
||||
feedToFillerConfig = feed;
|
||||
}
|
||||
|
||||
$scope.registerDeleteFiller = (feed) => {
|
||||
feedToDeleteFiller = feed;
|
||||
}
|
||||
|
||||
$scope.queryChannel = async (index, channel) => {
|
||||
let ch = await dizquetv.getChannelDescription(channel.number);
|
||||
ch.pending = false;
|
||||
$scope.fillers[index] = ch;
|
||||
$scope.$apply();
|
||||
}
|
||||
|
||||
$scope.onFillerConfigDone = async (filler) => {
|
||||
if ($scope.selectedChannelIndex != -1) {
|
||||
$scope.fillers[ $scope.selectedChannelIndex ].pending = false;
|
||||
}
|
||||
if (typeof filler !== 'undefined') {
|
||||
// not canceled
|
||||
if ($scope.selectedChannelIndex == -1) { // add new channel
|
||||
await dizquetv.createFiller(filler);
|
||||
} else {
|
||||
$scope.fillers[ $scope.selectedChannelIndex ].pending = true;
|
||||
await dizquetv.updateFiller(filler.id, filler);
|
||||
}
|
||||
await $scope.refreshFiller();
|
||||
}
|
||||
}
|
||||
$scope.selectFiller = async (index) => {
|
||||
try {
|
||||
if ( (index != -1) && $scope.fillers[index].pending) {
|
||||
return;
|
||||
}
|
||||
$scope.selectedChannelIndex = index;
|
||||
if (index === -1) {
|
||||
feedToFillerConfig();
|
||||
} else {
|
||||
$scope.fillers[index].pending = true;
|
||||
let f = await dizquetv.getFiller($scope.fillers[index].id);
|
||||
feedToFillerConfig(f);
|
||||
$timeout();
|
||||
}
|
||||
} catch( err ) {
|
||||
console.error("Could not fetch filler.", err);
|
||||
}
|
||||
}
|
||||
|
||||
$scope.deleteFiller = async (index) => {
|
||||
try {
|
||||
if ( $scope.fillers[index].pending) {
|
||||
return;
|
||||
}
|
||||
$scope.deleteFillerIndex = index;
|
||||
$scope.fillers[index].pending = true;
|
||||
let id = $scope.fillers[index].id;
|
||||
let channels = await dizquetv.getChannelsUsingFiller(id);
|
||||
feedToDeleteFiller( {
|
||||
id: id,
|
||||
name: $scope.fillers[index].name,
|
||||
channels : channels,
|
||||
} );
|
||||
$timeout();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Could not start delete filler dialog.", err);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$scope.onFillerDelete = async( id ) => {
|
||||
try {
|
||||
$scope.fillers[ $scope.deleteFillerIndex ].pending = false;
|
||||
$timeout();
|
||||
if (typeof(id) !== 'undefined') {
|
||||
$scope.fillers[ $scope.deleteFillerIndex ].pending = true;
|
||||
await dizquetv.deleteFiller(id);
|
||||
$timeout();
|
||||
await $scope.refreshFiller();
|
||||
$timeout();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error attempting to delete filler", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
363
web/controllers/guide.js
Normal file
@ -0,0 +1,363 @@
|
||||
const MINUTE = 60 * 1000;
|
||||
|
||||
module.exports = function ($scope, $timeout, dizquetv) {
|
||||
|
||||
$scope.offset = 0;
|
||||
$scope.M = 60 * MINUTE;
|
||||
$scope.zoomLevel = 3
|
||||
$scope.T = 190 * MINUTE;
|
||||
$scope.before = 15 * MINUTE;
|
||||
$scope.enableNext = false;
|
||||
$scope.enableBack = false;
|
||||
$scope.showNow = false;
|
||||
$scope.nowPosition = 0;
|
||||
$scope.refreshHandle = null;
|
||||
|
||||
const intl = new Intl.DateTimeFormat('default',
|
||||
{
|
||||
hour12: true,
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
});
|
||||
|
||||
let hourMinute = (d) => {
|
||||
return intl.format(d);
|
||||
};
|
||||
|
||||
$scope.updateBasics = () => {
|
||||
$scope.channelNumberWidth = 5;
|
||||
$scope.channelIconWidth = 8;
|
||||
$scope.channelWidth = $scope.channelNumberWidth + $scope.channelIconWidth;
|
||||
//we want 1 minute = 1 colspan
|
||||
$scope.colspanPercent = (100 - $scope.channelWidth) / ($scope.T / MINUTE);
|
||||
$scope.channelColspan = Math.floor($scope.channelWidth / $scope.colspanPercent);
|
||||
$scope.channelNumberColspan = Math.floor($scope.channelNumberWidth / $scope.colspanPercent);
|
||||
$scope.channelIconColspan = $scope.channelColspan - $scope.channelNumberColspan;
|
||||
$scope.totalSpan = Math.floor($scope.T / MINUTE);
|
||||
$scope.colspanPercent = (100 - $scope.channelWidth) / ($scope.T / MINUTE);
|
||||
$scope.channelColspan = Math.floor($scope.channelWidth / $scope.colspanPercent);
|
||||
$scope.channelNumberColspan = Math.floor($scope.channelNumberWidth / $scope.colspanPercent);
|
||||
$scope.channelIconColspan = $scope.channelColspan - $scope.channelNumberColspan;
|
||||
|
||||
}
|
||||
$scope.updateBasics();
|
||||
|
||||
$scope.channelNumberWidth = 5;
|
||||
$scope.channelIconWidth = 8;
|
||||
$scope.channelWidth = $scope.channelNumberWidth + $scope.channelIconWidth;
|
||||
//we want 1 minute = 1 colspan
|
||||
|
||||
|
||||
|
||||
$scope.applyLater = () => {
|
||||
$timeout( () => $scope.$apply(), 0 );
|
||||
};
|
||||
|
||||
$scope.channelNumbers = [];
|
||||
$scope.channels = {};
|
||||
$scope.lastUpdate = -1;
|
||||
|
||||
$scope.updateJustNow = () => {
|
||||
$scope.t1 = (new Date()).getTime();
|
||||
if ($scope.t0 <= $scope.t1 && $scope.t1 < $scope.t0 + $scope.T) {
|
||||
let n = ($scope.t1 - $scope.t0) / MINUTE;
|
||||
$scope.nowPosition = ($scope.channelColspan + n) * $scope.colspanPercent
|
||||
if ($scope.nowPosition >= 50 && $scope.offset >= 0) {
|
||||
$scope.offset = 0;
|
||||
$scope.adjustZoom();
|
||||
}
|
||||
$scope.showNow = true;
|
||||
} else {
|
||||
$scope.showNow = false;
|
||||
}
|
||||
}
|
||||
|
||||
$scope.nowTimer = () => {
|
||||
$scope.updateJustNow();
|
||||
$timeout( () => $scope.nowTimer() , 10000);
|
||||
}
|
||||
$timeout( () => $scope.nowTimer() , 10000);
|
||||
|
||||
|
||||
$scope.refreshManaged = async (skipStatus) => {
|
||||
$scope.t1 = (new Date()).getTime();
|
||||
$scope.t1 = ($scope.t1 - $scope.t1 % MINUTE );
|
||||
$scope.t0 = $scope.t1 - $scope.before + $scope.offset;
|
||||
$scope.times = [];
|
||||
|
||||
$scope.updateJustNow();
|
||||
let pending = 0;
|
||||
let addDuration = (d) => {
|
||||
let m = (pending + d) % MINUTE;
|
||||
let r = (pending + d) - m;
|
||||
pending = m;
|
||||
return Math.floor( r / MINUTE );
|
||||
}
|
||||
let deleteIfZero = () => {
|
||||
if ( $scope.times.length > 0 && $scope.times[$scope.times.length - 1].duration < 1) {
|
||||
$scope.times = $scope.times.slice(0, $scope.times.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let rem = $scope.T;
|
||||
let t = $scope.t0;
|
||||
if (t % $scope.M != 0) {
|
||||
let dif = $scope.M - t % $scope.M;
|
||||
$scope.times.push( {
|
||||
duration : addDuration(dif),
|
||||
} );
|
||||
deleteIfZero();
|
||||
t += dif;
|
||||
rem -= dif;
|
||||
}
|
||||
while (rem > 0) {
|
||||
let d = Math.min(rem, $scope.M );
|
||||
$scope.times.push( {
|
||||
duration : addDuration(d),
|
||||
label: hourMinute( new Date(t) ),
|
||||
} );
|
||||
t += d;
|
||||
rem -= d;
|
||||
}
|
||||
|
||||
if (skipStatus !== true) {
|
||||
$scope.channelNumbers = [0];
|
||||
$scope.channels = {} ;
|
||||
$scope.channels[0] = {
|
||||
loading: true,
|
||||
}
|
||||
$scope.applyLater();
|
||||
console.log("getting status...");
|
||||
let status = await dizquetv.getGuideStatus();
|
||||
$scope.lastUpdate = new Date(status.lastUpdate).getTime();
|
||||
console.log("got status: " + JSON.stringify(status) );
|
||||
$scope.channelNumbers = status.channelNumbers;
|
||||
$scope.channels = {} ;
|
||||
}
|
||||
|
||||
for (let i = 0; i < $scope.channelNumbers.length; i++) {
|
||||
if ( typeof($scope.channels[$scope.channelNumbers[i]]) === 'undefined') {
|
||||
$scope.channels[$scope.channelNumbers[i]] = {};
|
||||
}
|
||||
$scope.channels[$scope.channelNumbers[i]].loading = true;
|
||||
}
|
||||
$scope.applyLater();
|
||||
$scope.enableBack = false;
|
||||
$scope.enableNext = false;
|
||||
await Promise.all($scope.channelNumbers.map( $scope.loadChannel) );
|
||||
setupTimer();
|
||||
};
|
||||
|
||||
let cancelTimerIfExists = () => {
|
||||
if ($scope.refreshHandle != null) {
|
||||
$timeout.cancel($scope.refreshHandle);
|
||||
}
|
||||
}
|
||||
|
||||
$scope.$on('$locationChangeStart', () => {
|
||||
console.log("$locationChangeStart" );
|
||||
cancelTimerIfExists();
|
||||
} );
|
||||
|
||||
|
||||
let setupTimer = () => {
|
||||
cancelTimerIfExists();
|
||||
$scope.refreshHandle = $timeout( () => $scope.checkUpdates(), 60000 );
|
||||
}
|
||||
|
||||
$scope.adjustZoom = async() => {
|
||||
switch ($scope.zoomLevel) {
|
||||
case 1:
|
||||
$scope.T = 50 * MINUTE;
|
||||
$scope.M = 10 * MINUTE;
|
||||
$scope.before = 5 * MINUTE;
|
||||
break;
|
||||
case 2:
|
||||
$scope.T = 100 * MINUTE;
|
||||
$scope.M = 15 * MINUTE;
|
||||
$scope.before = 10 * MINUTE;
|
||||
break;
|
||||
case 3:
|
||||
$scope.T = 190 * MINUTE;
|
||||
$scope.M = 30 * MINUTE;
|
||||
$scope.before = 15 * MINUTE;
|
||||
break;
|
||||
case 4:
|
||||
$scope.T = 270 * MINUTE;
|
||||
$scope.M = 60 * MINUTE;
|
||||
$scope.before = 15 * MINUTE;
|
||||
break;
|
||||
case 5:
|
||||
$scope.T = 380 * MINUTE;
|
||||
$scope.M = 90 * MINUTE;
|
||||
$scope.before = 15 * MINUTE;
|
||||
break;
|
||||
}
|
||||
|
||||
$scope.updateBasics();
|
||||
await $scope.refresh(true);
|
||||
}
|
||||
|
||||
$scope.zoomOut = async() => {
|
||||
$scope.zoomLevel = Math.min( 5, $scope.zoomLevel + 1 );
|
||||
await $scope.adjustZoom();
|
||||
}
|
||||
$scope.zoomIn = async() => {
|
||||
$scope.zoomLevel = Math.max( 1, $scope.zoomLevel - 1 );
|
||||
await $scope.adjustZoom();
|
||||
}
|
||||
$scope.zoomOutEnabled = () => {
|
||||
return $scope.zoomLevel < 5;
|
||||
}
|
||||
$scope.zoomInEnabled = () => {
|
||||
return $scope.zoomLevel > 1;
|
||||
}
|
||||
|
||||
$scope.next = async() => {
|
||||
$scope.offset += $scope.M * 7 / 8
|
||||
await $scope.adjustZoom();
|
||||
}
|
||||
$scope.back = async() => {
|
||||
$scope.offset -= $scope.M * 7 / 8
|
||||
await $scope.adjustZoom();
|
||||
}
|
||||
$scope.backEnabled = () => {
|
||||
return $scope.enableBack;
|
||||
}
|
||||
$scope.nextEnabled = () => {
|
||||
return $scope.enableNext;
|
||||
}
|
||||
|
||||
$scope.loadChannel = async (number) => {
|
||||
console.log(`number=${number}` );
|
||||
let d0 = new Date($scope.t0);
|
||||
let d1 = new Date($scope.t0 + $scope.T);
|
||||
let lineup = await dizquetv.getChannelLineup(number, d0, d1);
|
||||
let ch = {
|
||||
icon : lineup.icon,
|
||||
number : lineup.number,
|
||||
name: lineup.name,
|
||||
altTitle: `${lineup.number} - ${lineup.name}`,
|
||||
programs: [],
|
||||
};
|
||||
|
||||
let pending = 0;
|
||||
let totalAdded = 0;
|
||||
let addDuration = (d) => {
|
||||
totalAdded += d;
|
||||
let m = (pending + d) % MINUTE;
|
||||
let r = (pending + d) - m;
|
||||
pending = m;
|
||||
return Math.floor( r / MINUTE );
|
||||
}
|
||||
|
||||
let deleteIfZero = () => {
|
||||
if ( ch.programs.length > 0 && ch.programs[ ch.programs.length - 1].duration < 1) {
|
||||
ch.programs = ch.programs.slice(0, ch.programs.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < lineup.programs.length; i++) {
|
||||
let program = lineup.programs[i];
|
||||
let ad = new Date(program.start);
|
||||
let bd = new Date(program.stop);
|
||||
let a = ad.getTime();
|
||||
let b = bd.getTime();
|
||||
let hasStart = true;
|
||||
let hasStop = true;
|
||||
if (a < $scope.t0) {
|
||||
//cut-off
|
||||
a = $scope.t0;
|
||||
hasStart = false;
|
||||
$scope.enableBack = true;
|
||||
} else if ( (a > $scope.t0) && (i == 0) ) {
|
||||
ch.programs.push( {
|
||||
duration: addDuration( (a - $scope.t0) ),
|
||||
showTitle: "",
|
||||
start: false,
|
||||
end: true,
|
||||
} );
|
||||
deleteIfZero();
|
||||
}
|
||||
if (b > $scope.t0 + $scope.T) {
|
||||
b = $scope.t0 + $scope.T;
|
||||
hasStop = false;
|
||||
$scope.enableNext = true;
|
||||
}
|
||||
let subTitle = undefined;
|
||||
let episodeTitle = undefined;
|
||||
let altTitle = hourMinute(ad) + "-" + hourMinute(bd);
|
||||
if (typeof(program.title) !== 'undefined') {
|
||||
altTitle = altTitle + " · " + program.title;
|
||||
}
|
||||
|
||||
if (typeof(program.sub) !== 'undefined') {
|
||||
ps = "" + program.sub.season;
|
||||
if (ps.length < 2) {
|
||||
ps = "0" + ps;
|
||||
}
|
||||
pe = "" + program.sub.episode;
|
||||
if (pe.length < 2) {
|
||||
pe = "0" + pe;
|
||||
}
|
||||
subTitle = `S${ps} · E${pe}`;
|
||||
altTitle = altTitle + " " + subTitle;
|
||||
episodeTitle = program.sub.title;
|
||||
} else if ( typeof(program.date) === 'undefined' ) {
|
||||
subTitle = '.';
|
||||
} else {
|
||||
subTitle = program.date.slice(0,4);
|
||||
}
|
||||
ch.programs.push( {
|
||||
duration: addDuration(b - a),
|
||||
altTitle: altTitle,
|
||||
showTitle: program.title, // movie title, episode title or track title
|
||||
subTitle: subTitle,
|
||||
episodeTitle : episodeTitle,
|
||||
start: hasStart,
|
||||
end: hasStop,
|
||||
} );
|
||||
deleteIfZero();
|
||||
}
|
||||
if (totalAdded < $scope.T) {
|
||||
ch.programs.push( {
|
||||
duration: addDuration( $scope.T - totalAdded ),
|
||||
showTitle: "",
|
||||
start: false,
|
||||
end: true,
|
||||
} );
|
||||
deleteIfZero();
|
||||
}
|
||||
$scope.channels[number] = ch;
|
||||
$scope.applyLater();
|
||||
}
|
||||
|
||||
|
||||
$scope.refresh = async (skipStatus) => {
|
||||
try {
|
||||
await $scope.refreshManaged(skipStatus);
|
||||
} catch (err) {
|
||||
console.error("Refresh failed?", err);
|
||||
}
|
||||
}
|
||||
|
||||
$scope.adjustZoom();
|
||||
$scope.refresh();
|
||||
|
||||
$scope.checkUpdates = async () => {
|
||||
try {
|
||||
console.log("get status " + new Date() );
|
||||
let status = await dizquetv.getGuideStatus();
|
||||
let t = new Date(status.lastUpdate).getTime();
|
||||
if ( t > $scope.lastUpdate) {
|
||||
$scope.refreshManaged();
|
||||
} else {
|
||||
setupTimer();
|
||||
}
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
2
web/controllers/library.js
Normal file
@ -0,0 +1,2 @@
|
||||
module.exports = function () {
|
||||
}
|
||||
73
web/controllers/player.js
Normal file
@ -0,0 +1,73 @@
|
||||
module.exports = function ($scope, dizquetv, $timeout) {
|
||||
|
||||
$scope.loading = true;
|
||||
$scope.channelOptions = [
|
||||
{ id: undefined, description: "Select a channel" },
|
||||
];
|
||||
$scope.icons = {};
|
||||
|
||||
|
||||
$scope.endpointOptions = [
|
||||
{ id: "video", description: "/video - Channel mpegts" },
|
||||
{ id: "m3u8", description: "/m3u8 - Playlist of individual videos" },
|
||||
{ id: "radio", description: "/radio - Audio-only channel mpegts" },
|
||||
];
|
||||
$scope.selectedEndpoint = "video";
|
||||
$scope.channel = undefined;
|
||||
|
||||
$scope.endpointButtonHref = () => {
|
||||
if ( $scope.selectedEndpoint == "video") {
|
||||
return `./media-player/${$scope.channel}.m3u`
|
||||
} else if ( $scope.selectedEndpoint == "m3u8") {
|
||||
return `./media-player/fast/${$scope.channel}.m3u`
|
||||
} else if ( $scope.selectedEndpoint == "radio") {
|
||||
return `./media-player/radio/${$scope.channel}.m3u`
|
||||
}
|
||||
}
|
||||
|
||||
$scope.buttonDisabled = () => {
|
||||
return typeof($scope.channel) === 'undefined';
|
||||
}
|
||||
|
||||
$scope.endpoint = () => {
|
||||
if ( typeof($scope.channel) === 'undefined' ) {
|
||||
return "--"
|
||||
}
|
||||
let path = "";
|
||||
if ( $scope.selectedEndpoint == "video") {
|
||||
path = `/video?channel=${$scope.channel}`
|
||||
} else if ( $scope.selectedEndpoint == "m3u8") {
|
||||
path = `/m3u8?channel=${$scope.channel}`
|
||||
} else if ( $scope.selectedEndpoint == "radio") {
|
||||
path= `/radio?channel=${$scope.channel}`
|
||||
}
|
||||
return window.location.href.replace("/#!/player", path);
|
||||
}
|
||||
|
||||
let loadChannels = async() => {
|
||||
let channelNumbers = await dizquetv.getChannelNumbers();
|
||||
try {
|
||||
await Promise.all( channelNumbers.map( async(x) => {
|
||||
let desc = await dizquetv.getChannelDescription(x);
|
||||
let option = {
|
||||
id: x,
|
||||
description: `${x} - ${desc.name}`,
|
||||
};
|
||||
$scope.channelOptions.push( option );
|
||||
$scope.icons[x] = desc.icon;
|
||||
}) );
|
||||
$scope.channelOptions.sort( (a,b) => {
|
||||
let za = ( (typeof(a.id) === undefined)?-1:a.id);
|
||||
let zb = ( (typeof(b.id) === undefined)?-1:b.id);
|
||||
return za - zb;
|
||||
} );
|
||||
$scope.loading = false;
|
||||
$scope.$apply();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
$timeout( () => $scope.$apply(), 0);
|
||||
}
|
||||
|
||||
loadChannels();
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
module.exports = function ($scope, pseudotv) {
|
||||
$scope.version = "Getting PseudoTV version..."
|
||||
pseudotv.getVersion().then((version) => {
|
||||
$scope.version = version.pseudotv
|
||||
module.exports = function ($scope, dizquetv) {
|
||||
$scope.version = ""
|
||||
$scope.ffmpegVersion = ""
|
||||
dizquetv.getVersion().then((version) => {
|
||||
$scope.version = version.dizquetv;
|
||||
$scope.ffmpegVersion = version.ffmpeg;
|
||||
$scope.nodejs = version.nodejs;
|
||||
})
|
||||
|
||||
|
||||
|
||||
85
web/directives/channel-redirect.js
Normal file
@ -0,0 +1,85 @@
|
||||
module.exports = function ($timeout, dizquetv) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/channel-redirect.html',
|
||||
replace: true,
|
||||
scope: {
|
||||
formTitle: "=formTitle",
|
||||
visible: "=visible",
|
||||
program: "=program",
|
||||
_onDone: "=onDone"
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
scope.error = "";
|
||||
scope.options = [];
|
||||
scope.loading = true;
|
||||
|
||||
scope.$watch('program', () => {
|
||||
if (typeof(scope.program) === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if ( isNaN(scope.program.duration) ) {
|
||||
scope.program.duration = 15000;
|
||||
}
|
||||
scope.durationSeconds = Math.ceil( scope.program.duration / 1000.0 );;
|
||||
})
|
||||
|
||||
scope.refreshChannels = async() => {
|
||||
let channelNumbers = await dizquetv.getChannelNumbers();
|
||||
try {
|
||||
await Promise.all( channelNumbers.map( async(x) => {
|
||||
let desc = await dizquetv.getChannelDescription(x);
|
||||
let option = {
|
||||
id: x,
|
||||
description: `${x} - ${desc.name}`,
|
||||
};
|
||||
let i = 0;
|
||||
while (i < scope.options.length) {
|
||||
if (scope.options[i].id == x) {
|
||||
scope.options[i] = option;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (i == scope.options.length) {
|
||||
scope.options.push(option);
|
||||
}
|
||||
scope.$apply();
|
||||
}) );
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
scope.options.sort( (a,b) => a.id - b.id );
|
||||
scope.loading = false;
|
||||
$timeout( () => scope.$apply(), 0);
|
||||
};
|
||||
scope.refreshChannels();
|
||||
|
||||
scope.onCancel = () => {
|
||||
scope.visible = false;
|
||||
}
|
||||
|
||||
scope.onDone = () => {
|
||||
scope.error = "";
|
||||
if (typeof(scope.program.channel) === 'undefined') {
|
||||
scope.error = "Please select a channel.";
|
||||
}
|
||||
if ( isNaN(scope.program.channel) ) {
|
||||
scope.error = "Channel must be a number.";
|
||||
}
|
||||
if ( isNaN(scope.durationSeconds) ) {
|
||||
scope.error = "Duration must be a number.";
|
||||
}
|
||||
if ( scope.error != "" ) {
|
||||
$timeout( () => scope.error = "", 60000);
|
||||
return;
|
||||
}
|
||||
scope.program.duration = scope.durationSeconds * 1000;
|
||||
scope._onDone( scope.program );
|
||||
scope.visible = false;
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
};
|
||||
}
|
||||
32
web/directives/delete-filler.js
Normal file
@ -0,0 +1,32 @@
|
||||
module.exports = function ($timeout) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/delete-filler.html',
|
||||
replace: true,
|
||||
scope: {
|
||||
linker: "=linker",
|
||||
onExit: "=onExit"
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
scope.name = '';
|
||||
scope.channels = [];
|
||||
scope.visible = false;
|
||||
|
||||
scope.linker( (filler) => {
|
||||
scope.name = filler.name;
|
||||
scope.id = filler.id;
|
||||
scope.channels = filler.channels;
|
||||
scope.visible = true;
|
||||
} );
|
||||
|
||||
scope.finished = (cancelled) => {
|
||||
scope.visible = false;
|
||||
if (! cancelled) {
|
||||
scope.onExit( scope.id );
|
||||
} else {
|
||||
scope.onExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
module.exports = function (pseudotv) {
|
||||
module.exports = function (dizquetv, resolutionOptions) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/ffmpeg-settings.html',
|
||||
@ -6,16 +6,22 @@
|
||||
scope: {
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
pseudotv.getFfmpegSettings().then((settings) => {
|
||||
//add validations to ffmpeg settings, speciall commas in codec name
|
||||
dizquetv.getFfmpegSettings().then((settings) => {
|
||||
scope.settings = settings
|
||||
})
|
||||
scope.updateSettings = (settings) => {
|
||||
pseudotv.updateFfmpegSettings(settings).then((_settings) => {
|
||||
delete scope.settingsError;
|
||||
dizquetv.updateFfmpegSettings(settings).then((_settings) => {
|
||||
scope.settings = _settings
|
||||
}).catch( (err) => {
|
||||
if ( typeof(err.data) === "string") {
|
||||
scope.settingsError = err.data;
|
||||
}
|
||||
})
|
||||
}
|
||||
scope.resetSettings = (settings) => {
|
||||
pseudotv.resetFfmpegSettings(settings).then((_settings) => {
|
||||
dizquetv.resetFfmpegSettings(settings).then((_settings) => {
|
||||
scope.settings = _settings
|
||||
})
|
||||
}
|
||||
@ -25,17 +31,7 @@
|
||||
scope.hideIfNotAutoPlay = () => {
|
||||
return scope.settings.enableAutoPlay != true
|
||||
};
|
||||
scope.resolutionOptions=[
|
||||
{id:"420x420",description:"420x420 (1:1)"},
|
||||
{id:"576x320",description:"576x320 (18:10)"},
|
||||
{id:"640×360",description:"640×360 (nHD 16:9)"},
|
||||
{id:"720x480",description:"720x480 (WVGA 3:2)"},
|
||||
{id:"800x600",description:"800x600 (SVGA 4:3)"},
|
||||
{id:"1024x768",description:"1024x768 (WXGA 4:3)"},
|
||||
{id:"1280x720",description:"1280x720 (HD 16:9)"},
|
||||
{id:"1920x1080",description:"1920x1080 (FHD 16:9)"},
|
||||
{id:"3840x2160",description:"3840x2160 (4K 16:9)"},
|
||||
];
|
||||
scope.resolutionOptions= resolutionOptions.get();
|
||||
scope.muxDelayOptions=[
|
||||
{id:"0",description:"0 Seconds"},
|
||||
{id:"1",description:"1 Seconds"},
|
||||
@ -58,6 +54,32 @@
|
||||
{value:"sine", description:"Beep"},
|
||||
{value:"silent", description:"No Audio"},
|
||||
]
|
||||
scope.fpsOptions = [
|
||||
{id: 23.976, description: "23.976 frames per second"},
|
||||
{id: 24, description: "24 frames per second"},
|
||||
{id: 25, description: "25 frames per second"},
|
||||
{id: 29.97, description: "29.97 frames per second"},
|
||||
{id: 30, description: "30 frames per second"},
|
||||
{id: 50, description: "50 frames per second"},
|
||||
{id: 59.94, description: "59.94 frames per second"},
|
||||
{id: 60, description: "60 frames per second"},
|
||||
{id: 120, description: "120 frames per second"},
|
||||
];
|
||||
scope.scalingOptions = [
|
||||
{id: "bicubic", description: "bicubic (default)"},
|
||||
{id: "fast_bilinear", description: "fast_bilinear"},
|
||||
{id: "lanczos", description: "lanczos"},
|
||||
{id: "spline", description: "spline"},
|
||||
];
|
||||
scope.deinterlaceOptions = [
|
||||
{value: "none", description: "do not deinterlace"},
|
||||
{value: "bwdif=0", description: "bwdif send frame"},
|
||||
{value: "bwdif=1", description: "bwdif send field"},
|
||||
{value: "w3fdif", description: "w3fdif"},
|
||||
{value: "yadif=0", description: "yadif send frame"},
|
||||
{value: "yadif=1", description: "yadif send field"}
|
||||
];
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
198
web/directives/filler-config.js
Normal file
@ -0,0 +1,198 @@
|
||||
module.exports = function ($timeout, commonProgramTools, getShowData) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/filler-config.html',
|
||||
replace: true,
|
||||
scope: {
|
||||
linker: "=linker",
|
||||
onDone: "=onDone"
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
scope.showTools = false;
|
||||
scope.showPlexLibrary = false;
|
||||
scope.content = [];
|
||||
scope.visible = false;
|
||||
scope.error = undefined;
|
||||
|
||||
function refreshContentIndexes() {
|
||||
for (let i = 0; i < scope.content.length; i++) {
|
||||
scope.content[i].$index = i;
|
||||
}
|
||||
}
|
||||
|
||||
scope.contentSplice = (a,b) => {
|
||||
scope.content.splice(a,b)
|
||||
refreshContentIndexes();
|
||||
}
|
||||
|
||||
scope.dropFunction = (dropIndex, program) => {
|
||||
let y = program.$index;
|
||||
let z = dropIndex + scope.currentStartIndex - 1;
|
||||
scope.content.splice(y, 1);
|
||||
if (z >= y) {
|
||||
z--;
|
||||
}
|
||||
scope.content.splice(z, 0, program );
|
||||
refreshContentIndexes();
|
||||
$timeout();
|
||||
return false;
|
||||
}
|
||||
scope.setUpWatcher = function setupWatchers() {
|
||||
this.$watch('vsRepeat.startIndex', function(val) {
|
||||
scope.currentStartIndex = val;
|
||||
});
|
||||
};
|
||||
|
||||
scope.movedFunction = (index) => {
|
||||
console.log("movedFunction(" + index + ")");
|
||||
}
|
||||
|
||||
|
||||
|
||||
scope.linker( (filler) => {
|
||||
if ( typeof(filler) === 'undefined') {
|
||||
scope.name = "";
|
||||
scope.content = [];
|
||||
scope.id = undefined;
|
||||
scope.title = "Create Filler List";
|
||||
} else {
|
||||
scope.name = filler.name;
|
||||
scope.content = filler.content;
|
||||
scope.id = filler.id;
|
||||
scope.title = "Edit Filler List";
|
||||
}
|
||||
refreshContentIndexes();
|
||||
scope.visible = true;
|
||||
} );
|
||||
|
||||
scope.finished = (cancelled) => {
|
||||
if (cancelled) {
|
||||
scope.visible = false;
|
||||
return scope.onDone();
|
||||
}
|
||||
if ( (typeof(scope.name) === 'undefined') || (scope.name.length == 0) ) {
|
||||
scope.error = "Please enter a name";
|
||||
}
|
||||
if ( scope.content.length == 0) {
|
||||
scope.error = "Please add at least one clip.";
|
||||
}
|
||||
if (typeof(scope.error) !== 'undefined') {
|
||||
$timeout( () => {
|
||||
scope.error = undefined;
|
||||
}, 30000);
|
||||
return;
|
||||
}
|
||||
scope.visible = false;
|
||||
scope.onDone( {
|
||||
name: scope.name,
|
||||
content: scope.content.map( (c) => {
|
||||
delete c.$index
|
||||
return c;
|
||||
} ),
|
||||
id: scope.id,
|
||||
} );
|
||||
}
|
||||
scope.getText = (clip) => {
|
||||
let show = getShowData(clip);
|
||||
if (show.hasShow && show.showId !== "movie." ) {
|
||||
return show.showDisplayName + " - " + clip.title;
|
||||
} else {
|
||||
return clip.title;
|
||||
}
|
||||
}
|
||||
scope.showList = () => {
|
||||
return ! scope.showPlexLibrary;
|
||||
}
|
||||
scope.sortFillersByLength = () => {
|
||||
scope.content.sort( (a,b) => { return a.duration - b.duration } );
|
||||
refreshContentIndexes();
|
||||
}
|
||||
scope.sortFillersCorrectly = () => {
|
||||
scope.content = commonProgramTools.sortShows(scope.content);
|
||||
refreshContentIndexes();
|
||||
}
|
||||
|
||||
scope.fillerRemoveAllFiller = () => {
|
||||
scope.content = [];
|
||||
refreshContentIndexes();
|
||||
}
|
||||
scope.fillerRemoveDuplicates = () => {
|
||||
function getKey(p) {
|
||||
return p.serverKey + "|" + p.plexFile;
|
||||
}
|
||||
let seen = {};
|
||||
let newFiller = [];
|
||||
for (let i = 0; i < scope.content.length; i++) {
|
||||
let p = scope.content[i];
|
||||
let k = getKey(p);
|
||||
if ( typeof(seen[k]) === 'undefined') {
|
||||
seen[k] = true;
|
||||
newFiller.push(p);
|
||||
}
|
||||
}
|
||||
scope.content = newFiller;
|
||||
refreshContentIndexes();
|
||||
}
|
||||
scope.importPrograms = (selectedPrograms) => {
|
||||
for (let i = 0, l = selectedPrograms.length; i < l; i++) {
|
||||
selectedPrograms[i].commercials = []
|
||||
}
|
||||
scope.content = scope.content.concat(selectedPrograms);
|
||||
refreshContentIndexes();
|
||||
scope.showPlexLibrary = false;
|
||||
}
|
||||
|
||||
|
||||
scope.durationString = (duration) => {
|
||||
var date = new Date(0);
|
||||
date.setSeconds( Math.floor(duration / 1000) ); // specify value for SECONDS here
|
||||
return date.toISOString().substr(11, 8);
|
||||
}
|
||||
|
||||
let interpolate = ( () => {
|
||||
let h = 60*60*1000 / 6;
|
||||
let ix = [0, 1*h, 2*h, 4*h, 8*h, 24*h];
|
||||
let iy = [0, 1.0, 1.25, 1.5, 1.75, 2.0];
|
||||
let n = ix.length;
|
||||
|
||||
return (x) => {
|
||||
for (let i = 0; i < n-1; i++) {
|
||||
if( (ix[i] <= x) && ( (x < ix[i+1]) || i==n-2 ) ) {
|
||||
return iy[i] + (iy[i+1] - iy[i]) * ( (x - ix[i]) / (ix[i+1] - ix[i]) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} )();
|
||||
|
||||
scope.programSquareStyle = (program, dash) => {
|
||||
let background = "rgb(255, 255, 255)";
|
||||
let ems = Math.pow( Math.min(60*60*1000, program.duration), 0.7 );
|
||||
ems = ems / Math.pow(1*60*1000., 0.7);
|
||||
ems = Math.max( 0.25 , ems);
|
||||
let top = Math.max(0.0, (1.75 - ems) / 2.0) ;
|
||||
if (top == 0.0) {
|
||||
top = "1px";
|
||||
}
|
||||
let solidOrDash = (dash? 'dashed' : 'solid');
|
||||
let f = interpolate;
|
||||
let w = 5.0;
|
||||
let t = 4*60*60*1000;
|
||||
let a = ( f(program.duration) *w) / f(t);
|
||||
a = Math.min( w, Math.max(0.3, a) );
|
||||
b = w - a + 0.01;
|
||||
|
||||
return {
|
||||
'width': `${a}%`,
|
||||
'height': '1.3em',
|
||||
'margin-right': `${b}%`,
|
||||
'background': background,
|
||||
'border': `1px ${solidOrDash} black`,
|
||||
'margin-top': top,
|
||||
'margin-bottom': '1px',
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
}
|
||||
46
web/directives/flex-config.js
Normal file
@ -0,0 +1,46 @@
|
||||
module.exports = function ($timeout, dizquetv) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/flex-config.html',
|
||||
replace: true,
|
||||
scope: {
|
||||
title: "@offlineTitle",
|
||||
program: "=program",
|
||||
visible: "=visible",
|
||||
onDone: "=onDone"
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
let updateNext = true;
|
||||
scope.$watch('program', () => {
|
||||
try {
|
||||
if ( (typeof(scope.program) === 'undefined') || (scope.program == null) ) {
|
||||
updateNext = true;
|
||||
return;
|
||||
} else if (! updateNext) {
|
||||
return;
|
||||
}
|
||||
updateNext = false;
|
||||
scope.error = null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
})
|
||||
|
||||
scope.finished = (prog) => {
|
||||
scope.error = null;
|
||||
if (isNaN(prog.durationSeconds) || prog.durationSeconds < 0 ) {
|
||||
scope.error = { duration: 'Duration must be a positive integer' }
|
||||
}
|
||||
if (scope.error != null) {
|
||||
$timeout(() => {
|
||||
scope.error = null
|
||||
}, 30000)
|
||||
return
|
||||
}
|
||||
scope.onDone(JSON.parse(angular.toJson(prog)))
|
||||
scope.program = null
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
}
|
||||
24
web/directives/frequency-tweak.js
Normal file
@ -0,0 +1,24 @@
|
||||
module.exports = function ($timeout) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/frequency-tweak.html',
|
||||
replace: true,
|
||||
scope: {
|
||||
programs: "=programs",
|
||||
visible: "=visible",
|
||||
onDone: "=onDone",
|
||||
modified: "=modified",
|
||||
message: "=message",
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
scope.setModified = () => {
|
||||
scope.modified = true;
|
||||
}
|
||||
scope.finished = (programs) => {
|
||||
let p = programs;
|
||||
scope.programs = null;
|
||||
scope.onDone(p);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
module.exports = function (pseudotv, $timeout) {
|
||||
module.exports = function (dizquetv, $timeout) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/hdhr-settings.html',
|
||||
@ -6,7 +6,7 @@ module.exports = function (pseudotv, $timeout) {
|
||||
scope: {
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
pseudotv.getHdhrSettings().then((settings) => {
|
||||
dizquetv.getHdhrSettings().then((settings) => {
|
||||
scope.settings = settings
|
||||
})
|
||||
scope.updateSettings = (settings) => {
|
||||
@ -19,12 +19,12 @@ module.exports = function (pseudotv, $timeout) {
|
||||
$timeout(() => {
|
||||
scope.error = null
|
||||
}, 3500)
|
||||
pseudotv.updateHdhrSettings(settings).then((_settings) => {
|
||||
dizquetv.updateHdhrSettings(settings).then((_settings) => {
|
||||
scope.settings = _settings
|
||||
})
|
||||
}
|
||||
scope.resetSettings = (settings) => {
|
||||
pseudotv.resetHdhrSettings(settings).then((_settings) => {
|
||||
dizquetv.resetHdhrSettings(settings).then((_settings) => {
|
||||
scope.settings = _settings
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
module.exports = function ($timeout) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'templates/offline-config.html',
|
||||
replace: true,
|
||||
scope: {
|
||||
title: "@offlineTitle",
|
||||
program: "=program",
|
||||
visible: "=visible",
|
||||
onDone: "=onDone"
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
scope.showPlexLibrary = false;
|
||||
scope.showFallbackPlexLibrary = false;
|
||||
scope.finished = (prog) => {
|
||||
if (
|
||||
prog.channelOfflineMode != 'pic'
|
||||
&& (prog.fallback.length == 0)
|
||||
) {
|
||||
scope.error = { fallback: 'Either add a fallback clip or change the fallback mode to Picture.' }
|
||||
}
|
||||
if (isNaN(prog.durationSeconds) || prog.durationSeconds < 0 ) {
|
||||
scope.error = { duration: 'Duration must be a positive integer' }
|
||||
}
|
||||
if (scope.error != null) {
|
||||
$timeout(() => {
|
||||
scope.error = null
|
||||
}, 3500)
|
||||
return
|
||||
}
|
||||
|
||||
scope.onDone(JSON.parse(angular.toJson(prog)))
|
||||
scope.program = null
|
||||
}
|
||||
scope.importPrograms = (selectedPrograms) => {
|
||||
for (let i = 0, l = selectedPrograms.length; i < l; i++) {
|
||||
selectedPrograms[i].commercials = []
|
||||
}
|
||||
scope.program.filler = scope.program.filler.concat(selectedPrograms);
|
||||
}
|
||||
|
||||
scope.importFallback = (selectedPrograms) => {
|
||||
for (let i = 0, l = selectedPrograms.length; i < l && i < 1; i++) {
|
||||
selectedPrograms[i].commercials = []
|
||||
}
|
||||
scope.program.fallback = [];
|
||||
if (selectedPrograms.length > 0) {
|
||||
scope.program.fallback = [ selectedPrograms[0] ];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
scope.durationString = (duration) => {
|
||||
var date = new Date(0);
|
||||
date.setSeconds( Math.floor(duration / 1000) ); // specify value for SECONDS here
|
||||
return date.toISOString().substr(11, 8);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||