Compare commits
855
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e363ab00bb | ||
|
|
dd9a6d5a06 | ||
|
|
fde05a0299 | ||
|
|
d3f8163580 | ||
|
|
07e4ff907f | ||
|
|
34874ac548 | ||
|
|
03e4c0207b | ||
|
|
b9faf87887 | ||
|
|
2257d26173 | ||
|
|
8f6d208e31 | ||
|
|
5ccea53131 | ||
|
|
da6cb09658 | ||
|
|
260949893c | ||
|
|
89b495dc90 | ||
|
|
74d6b32828 | ||
|
|
626af6876b | ||
|
|
2a05cc6e32 | ||
|
|
7a4c832156 | ||
|
|
011f16da9f | ||
|
|
79496e688b | ||
|
|
5c43ae47b1 | ||
|
|
c29788bc3f | ||
|
|
3501e7c8d5 | ||
|
|
867c88d8fc | ||
|
|
70fbd4c746 | ||
|
|
1cbd48cea0 | ||
|
|
c953176cee | ||
|
|
e0cef62969 | ||
|
|
9e56f6552f | ||
|
|
6a84c564d6 | ||
|
|
54be3761dd | ||
|
|
cf6b9cf29a | ||
|
|
464c1e2ea8 | ||
|
|
107e8cfded | ||
|
|
837f824660 | ||
|
|
223bdff8d6 | ||
|
|
578cdb1e14 | ||
|
|
848b88bd2d | ||
|
|
b85571b159 | ||
|
|
43e1cbd919 | ||
|
|
39b107eb0f | ||
|
|
0ee62dbc7d | ||
|
|
833bf3506a | ||
|
|
cd75046348 | ||
|
|
448d29546c | ||
|
|
f2c49bd0fd | ||
|
|
174c743cb7 | ||
|
|
2a9f23cce6 | ||
|
|
451c534062 | ||
|
|
e16cb30ab1 | ||
|
|
e0df454ac6 | ||
|
|
e79a03b522 | ||
|
|
1a09bb26d7 | ||
|
|
ffd3e3604c | ||
|
|
7e40a809ff | ||
|
|
cecf18a7b5 | ||
|
|
7df33425fa | ||
|
|
5dfaa1a7ad | ||
|
|
28a65e74bb | ||
|
|
4a66f0ae43 | ||
|
|
fb2466d32d | ||
|
|
beaaa62ed9 | ||
|
|
0b445f8cfd | ||
|
|
7e30444857 | ||
|
|
fa6a31b4fc | ||
|
|
b01ad9dbae | ||
|
|
d324967afa | ||
|
|
aff4fb0deb | ||
|
|
93afcd2f57 | ||
|
|
921a108684 | ||
|
|
a6fa93d44e | ||
|
|
a42234a7c3 | ||
|
|
7c5137a4af | ||
|
|
5a9d27e196 | ||
|
|
cd4a9c1d16 | ||
|
|
f6249d9fa4 | ||
|
|
e2ffa70529 | ||
|
|
3e07bc6136 | ||
|
|
d6bfc2fd05 | ||
|
|
35116c64cd | ||
|
|
037cee873f | ||
|
|
cd28afcd91 | ||
|
|
7457301d3e | ||
|
|
7b7d378df7 | ||
|
|
f6dcaf9108 | ||
|
|
6cc2f1de17 | ||
|
|
c6ee41484e | ||
|
|
36d38c740f | ||
|
|
0f795e4e2f | ||
|
|
583cbf7b14 | ||
|
|
27c701b936 | ||
|
|
6e2c19d354 | ||
|
|
4d83dc019c | ||
|
|
462057a4b1 | ||
|
|
a04c72788f | ||
|
|
f94a440b62 | ||
|
|
f80069bb97 | ||
|
|
c2769a08b4 | ||
|
|
e679fee940 | ||
|
|
2271d5497b | ||
|
|
f71b6527c0 | ||
|
|
20d2fe71cd | ||
|
|
1994f171d5 | ||
|
|
76f7c88375 | ||
|
|
3805b9e48c | ||
|
|
9267edbcc9 | ||
|
|
c4c164df6a | ||
|
|
b90463e3af | ||
|
|
1fb27e3cfa | ||
|
|
06f233e5bd | ||
|
|
9917774671 | ||
|
|
5be929da18 | ||
|
|
25f4fb22e5 | ||
|
|
b04b517f7b | ||
|
|
d756c0c7c0 | ||
|
|
20e5b8a11a | ||
|
|
5c8489cbed | ||
|
|
7cfa298c72 | ||
|
|
4b0faf4da1 | ||
|
|
07cbf9936b | ||
|
|
2138d6437c | ||
|
|
5b9601a57b | ||
|
|
aeda5050d3 | ||
|
|
ea46a7a5ca | ||
|
|
69a1e718df | ||
|
|
4a59dafe51 | ||
|
|
6b90da8982 | ||
|
|
1184dc565c | ||
|
|
d82ccf8fb5 | ||
|
|
4d83cc705f | ||
|
|
f80addacba | ||
|
|
18c2a816dc | ||
|
|
4f085c1950 | ||
|
|
dad0662fa6 | ||
|
|
dfdfa6f349 | ||
|
|
5fe3e97b31 | ||
|
|
da1cfab5f4 | ||
|
|
6d152e4b4a | ||
|
|
2ca722523b | ||
|
|
d9a3496bf5 | ||
|
|
c8f5b51d93 | ||
|
|
956734ce39 | ||
|
|
e44a391f00 | ||
|
|
b43b66dd35 | ||
|
|
140a663da4 | ||
|
|
876d79c11c | ||
|
|
57a4480c3f | ||
|
|
8f1b57eb88 | ||
|
|
70472ac84e | ||
|
|
6aab8f53b8 | ||
|
|
b30b458574 | ||
|
|
6fe6382485 | ||
|
|
100ae0adda | ||
|
|
f0d5200843 | ||
|
|
af36218cc2 | ||
|
|
eca62e8bec | ||
|
|
03d4ab8c72 | ||
|
|
57b590cd0f | ||
|
|
f0a5d89f73 | ||
|
|
17a77694a0 | ||
|
|
838c2a1661 | ||
|
|
375a306edc | ||
|
|
759052c725 | ||
|
|
dc112f0c7d | ||
|
|
c8ec87b01f | ||
|
|
fbb7a661fb | ||
|
|
37ceac5651 | ||
|
|
0953e258a5 | ||
|
|
fdbd8a07b6 | ||
|
|
5465c45a51 | ||
|
|
a0bef3568b | ||
|
|
f75bb25a1a | ||
|
|
e4ff825ae8 | ||
|
|
6c4f63ad91 | ||
|
|
c0a14ba81c | ||
|
|
c063720169 | ||
|
|
5d0f40978d | ||
|
|
063f463951 | ||
|
|
e7817b1460 | ||
|
|
7d6faee27b | ||
|
|
7bba422880 | ||
|
|
db6ae27384 | ||
|
|
4b9fc5004f | ||
|
|
f40eaef898 | ||
|
|
91e85cc9c1 | ||
|
|
2c44efb971 | ||
|
|
c2b7be66af | ||
|
|
8b911332a6 | ||
|
|
4130f7316c | ||
|
|
3f6eb5a121 | ||
|
|
1209c54eb9 | ||
|
|
94db4bf679 | ||
|
|
2977590a14 | ||
|
|
b4c168e85e | ||
|
|
55b7a35689 | ||
|
|
a24592a8c4 | ||
|
|
9b60ff0863 | ||
|
|
efdf0bb6d4 | ||
|
|
39ca27cb3d | ||
|
|
9e2f7b7815 | ||
|
|
101d46e283 | ||
|
|
521e4eac41 | ||
|
|
894fc284b2 | ||
|
|
a8cf22e43e | ||
|
|
4c9c047530 | ||
|
|
912f79097d | ||
|
|
8aa55fdfce | ||
|
|
8dc1cab222 | ||
|
|
961fe8bbf2 | ||
|
|
75f991d670 | ||
|
|
e3c981004b | ||
|
|
befaa037e2 | ||
|
|
5e0fb31069 | ||
|
|
7d83e66ba6 | ||
|
|
391528cd94 | ||
|
|
b737775f9a | ||
|
|
728c5130b5 | ||
|
|
e4253276e0 | ||
|
|
1fc55bc693 | ||
|
|
4ad22e402f | ||
|
|
ec99d5976d | ||
|
|
59f11f1a1a | ||
|
|
694f25f8b3 | ||
|
|
5947555e86 | ||
|
|
fb63116b36 | ||
|
|
56a58d7a84 | ||
|
|
6f66909957 | ||
|
|
01090f62e6 | ||
|
|
e4e4f68eb4 | ||
|
|
8488fe5d3d | ||
|
|
f06ef5262a | ||
|
|
ae6bcc4933 | ||
|
|
b83fe53ef1 | ||
|
|
d50f2ace07 | ||
|
|
23684f607a | ||
|
|
fa20c5e01e | ||
|
|
53bd745678 | ||
|
|
f3e5a4e7d8 | ||
|
|
0b29bb32b1 | ||
|
|
d9a7615cf6 | ||
|
|
50f2cb7a33 | ||
|
|
b1b2c2a1e0 | ||
|
|
d842cd57f6 | ||
|
|
4f393d7b06 | ||
|
|
46f7289db8 | ||
|
|
80ccbbf299 | ||
|
|
3765894cb7 | ||
|
|
a8b658a5ea | ||
|
|
0e3c32bd83 | ||
|
|
9dd4a85bf9 | ||
|
|
a0a047ba18 | ||
|
|
687a4f4f10 | ||
|
|
b91ab5d898 | ||
|
|
256042947d | ||
|
|
85029cbbcd | ||
|
|
b5d679212d | ||
|
|
36e86587ef | ||
|
|
f41fa669be | ||
|
|
f62e841af4 | ||
|
|
109960c457 | ||
|
|
6858103be5 | ||
|
|
f7f1a0493b | ||
|
|
6c18648fd7 | ||
|
|
ab3afcfad0 | ||
|
|
5734356d29 | ||
|
|
beee038be3 | ||
|
|
a5c8e2b401 | ||
|
|
c8113bdf25 | ||
|
|
9466cf7626 | ||
|
|
43fcf9e63a | ||
|
|
242a7ae382 | ||
|
|
6dc526a817 | ||
|
|
182c02b865 | ||
|
|
7c1f0d6dbd | ||
|
|
2c97c49763 | ||
|
|
f5038c2b66 | ||
|
|
85eb7623da | ||
|
|
a87ec2d75d | ||
|
|
ef6c8b0816 | ||
|
|
dc3578660f | ||
|
|
ce958bb7bb | ||
|
|
33a8b29a27 | ||
|
|
48a504735c | ||
|
|
0b64e97df6 | ||
|
|
5d89f5d0a4 | ||
|
|
50a6ed4cea | ||
|
|
66b8c8aa0e | ||
|
|
398a3c041a | ||
|
|
6ce7265427 | ||
|
|
b4fe38d4ae | ||
|
|
e19b639527 | ||
|
|
a6d5df3ca6 | ||
|
|
202ae33e37 | ||
|
|
c46b3787d8 | ||
|
|
706a2d14a2 | ||
|
|
11f99216a3 | ||
|
|
b9a7ad2f5a | ||
|
|
07e7e5fe66 | ||
|
|
4a19d046e4 | ||
|
|
c1d6ddcc57 | ||
|
|
35eb200aee | ||
|
|
19af303d76 | ||
|
|
da20393a39 | ||
|
|
d78110f6f1 | ||
|
|
c1bedb661c | ||
|
|
d31d6f20cf | ||
|
|
7469559bb3 | ||
|
|
af5dc0968b | ||
|
|
6e7880386b | ||
|
|
8945a87f9b | ||
|
|
e305222141 | ||
|
|
2e2523c380 | ||
|
|
b461631be9 | ||
|
|
19fc068a73 | ||
|
|
e5f15df196 | ||
|
|
6ac2384cbc | ||
|
|
cd6673da82 | ||
|
|
8113827802 | ||
|
|
4e56117e0e | ||
|
|
7702999b9a | ||
|
|
14a707a4e2 | ||
|
|
340ab61a26 | ||
|
|
d91d991251 | ||
|
|
3ce341eee5 | ||
|
|
476fe991b6 | ||
|
|
39df3504fc | ||
|
|
60bb369d0c | ||
|
|
aae704f3a5 | ||
|
|
a45583d77a | ||
|
|
923b36604c | ||
|
|
b21d16b0f1 | ||
|
|
a5aaceeee5 | ||
|
|
e52d45fcf8 | ||
|
|
21d39bc26f | ||
|
|
233a1c228a | ||
|
|
56988be57b | ||
|
|
aded03d962 | ||
|
|
2119c88c97 | ||
|
|
a5d83a970a | ||
|
|
986785d863 | ||
|
|
087901d177 | ||
|
|
70c4036dc9 | ||
|
|
955add1efd | ||
|
|
99cd01f73b | ||
|
|
ef29e8c5a1 | ||
|
|
3b4c993530 | ||
|
|
bcc58bd668 | ||
|
|
6957a76156 | ||
|
|
4bafc316cc | ||
|
|
35817f09ac | ||
|
|
f4520a5520 | ||
|
|
3a906816fc | ||
|
|
707292c50f | ||
|
|
71e9ea867a | ||
|
|
c490832f66 | ||
|
|
e00568cc23 | ||
|
|
356e0f101a | ||
|
|
1f6e843a26 | ||
|
|
9587692486 | ||
|
|
f8c4f44216 | ||
|
|
d55ba235bf | ||
|
|
60b479e330 | ||
|
|
b866d07911 | ||
|
|
93db79f8c4 | ||
|
|
a15854d0ad | ||
|
|
c743d07425 | ||
|
|
8c3b8e81ca | ||
|
|
49050a57d2 | ||
|
|
49c53c5c96 | ||
|
|
1510c56e69 | ||
|
|
3ec610d65f | ||
|
|
69f9b6f137 | ||
|
|
08837bda80 | ||
|
|
9089e2ee04 | ||
|
|
abed22b560 | ||
|
|
e0f9ab4b88 | ||
|
|
231a214223 | ||
|
|
82bfa8019e | ||
|
|
d9bbe4df1b | ||
|
|
e0aa44d41b | ||
|
|
3d99c2593d | ||
|
|
d6dfc1edaa | ||
|
|
7d5cd229d4 | ||
|
|
cd0219c5c3 | ||
|
|
4cf8b83de4 | ||
|
|
6923b25177 | ||
|
|
5dce905b8e | ||
|
|
46c26b5ea7 | ||
|
|
7fffc8cf63 | ||
|
|
18deff0b83 | ||
|
|
16007a888e | ||
|
|
7eb1227ba4 | ||
|
|
1d1d5bf9bc | ||
|
|
45c04366c9 | ||
|
|
60b3bc92f4 | ||
|
|
12234c3e21 | ||
|
|
d37ce2d38a | ||
|
|
6f49233864 | ||
|
|
a67a6047c1 | ||
|
|
33f67b88f0 | ||
|
|
b88deaafe5 | ||
|
|
83fc3081d8 | ||
|
|
15d4b0f82b | ||
|
|
88fac0de04 | ||
|
|
4805d0d40f | ||
|
|
ef3b941a39 | ||
|
|
a59f71039c | ||
|
|
1ad42fffb1 | ||
|
|
2ce8db9e01 | ||
|
|
c409fd8b47 | ||
|
|
907b8074f1 | ||
|
|
adbd0bcec0 | ||
|
|
2c4379886a | ||
|
|
caef4a139e | ||
|
|
dcbe4837bf | ||
|
|
5e530b9301 | ||
|
|
2a28bf68bf | ||
|
|
f39eac97c0 | ||
|
|
9fd6589831 | ||
|
|
e2a516f5e8 | ||
|
|
64502315a3 | ||
|
|
56bc58fce9 | ||
|
|
0330b9326d | ||
|
|
6708d6b4d7 | ||
|
|
c18be5559b | ||
|
|
18ed20e203 | ||
|
|
965c7d0eac | ||
|
|
545bf1b775 | ||
|
|
bb299d4ee7 | ||
|
|
0e6c7d2bc3 | ||
|
|
576f0cd7e7 | ||
|
|
9471cb55dd | ||
|
|
3a84af1626 | ||
|
|
3d3bb64844 | ||
|
|
8fc1f36638 | ||
|
|
1823a5bae5 | ||
|
|
fc871e6f74 | ||
|
|
24780cbe84 | ||
|
|
c6ed258021 | ||
|
|
7586647b73 | ||
|
|
d91e945124 | ||
|
|
9dabffbac1 | ||
|
|
d310b5c09d | ||
|
|
ba48b3a676 | ||
|
|
d8a51b5d6d | ||
|
|
97674cff89 | ||
|
|
4820615308 | ||
|
|
1ddf27ce88 | ||
|
|
cd98a89acd | ||
|
|
a2a6afc3e3 | ||
|
|
dfaba8c7b0 | ||
|
|
5d11a6b46f | ||
|
|
b95a89b11f | ||
|
|
948b3735bd | ||
|
|
5ecf271773 | ||
|
|
b287c0d6ec | ||
|
|
b667659c05 | ||
|
|
22d3025e8e | ||
|
|
8f5b181372 | ||
|
|
f5060522aa | ||
|
|
14a88bd225 | ||
|
|
0550c60a78 | ||
|
|
d3bdcf9bc4 | ||
|
|
714f68a887 | ||
|
|
17bed524f2 | ||
|
|
c3fe263978 | ||
|
|
5291832e6c | ||
|
|
b39dd693f0 | ||
|
|
46bf9ef990 | ||
|
|
bc845b1327 | ||
|
|
3ab8e5bc3a | ||
|
|
e8bc051f73 | ||
|
|
b008fcfd85 | ||
|
|
547db5fb51 | ||
|
|
58fae1b0cc | ||
|
|
694b6bbd91 | ||
|
|
e0f8b7d7ae | ||
|
|
b16215fcd6 | ||
|
|
85f2c658aa | ||
|
|
78356314e6 | ||
|
|
b00a25bbee | ||
|
|
4d77576be2 | ||
|
|
a90348740d | ||
|
|
8081845ef1 | ||
|
|
d014eb4274 | ||
|
|
8c9cf7b6f2 | ||
|
|
5d9c8d4f7d | ||
|
|
82de3136cd | ||
|
|
b1cd324f9c | ||
|
|
5caf8f7f98 | ||
|
|
245c4ec359 | ||
|
|
6414471ace | ||
|
|
8b0b927a5c | ||
|
|
e5962699a4 | ||
|
|
27504e42bc | ||
|
|
deb0ac49b5 | ||
|
|
225b95449c | ||
|
|
cb43c28d00 | ||
|
|
0a75136223 | ||
|
|
efc710749e | ||
|
|
8f241f49fc | ||
|
|
20d224fcfd | ||
|
|
b3fda4e88d | ||
|
|
560cb826b3 | ||
|
|
b038a58fa2 | ||
|
|
c28e201e47 | ||
|
|
b84bb6b437 | ||
|
|
641b8bcd10 | ||
|
|
4b08ed5381 | ||
|
|
5486dcdcab | ||
|
|
77b32b0f09 | ||
|
|
2c6c08becf | ||
|
|
99bc19cf26 | ||
|
|
a7661c8498 | ||
|
|
d951035183 | ||
|
|
097c60169c | ||
|
|
d64d8b0454 | ||
|
|
7486304ed9 | ||
|
|
c62cc98c9f | ||
|
|
22a13cb1b3 | ||
|
|
5e573461f3 | ||
|
|
76c596a7d8 | ||
|
|
f945f16d97 | ||
|
|
797d4005e2 | ||
|
|
55903430ae | ||
|
|
f929dc92d1 | ||
|
|
2ad27c2be0 | ||
|
|
df2db5caf7 | ||
|
|
5978e8ecb1 | ||
|
|
a540efc2e1 | ||
|
|
1938cef6ae | ||
|
|
b23d798aff | ||
|
|
ebad7664b0 | ||
|
|
a9c93ff498 | ||
|
|
8277894f7b | ||
|
|
0d66f752b6 | ||
|
|
c128f72a54 | ||
|
|
4af2d7aa61 | ||
|
|
20a6727158 | ||
|
|
52e1874426 | ||
|
|
015f5e9798 | ||
|
|
1fc461e476 | ||
|
|
85792f0811 | ||
|
|
0f91a43e3f | ||
|
|
7a25996ab4 | ||
|
|
6985826072 | ||
|
|
52482ef2fb | ||
|
|
c148f2eb11 | ||
|
|
d490cc6f4b | ||
|
|
99bd827bd9 | ||
|
|
e8cbcc935f | ||
|
|
a2acfe4d80 | ||
|
|
5da2bdbab4 | ||
|
|
66607b95bb | ||
|
|
81a6251f65 | ||
|
|
c554d83d60 | ||
|
|
875010bbf4 | ||
|
|
c5692ef5f1 | ||
|
|
147ab6143d | ||
|
|
aca441074e | ||
|
|
ef6adf9cbb | ||
|
|
ddb7e1887f | ||
|
|
4997699b4d | ||
|
|
c27b906cd5 | ||
|
|
bec3cb864d | ||
|
|
03df2a6c8a | ||
|
|
6142dcf153 | ||
|
|
b287f791e6 | ||
|
|
2ccba9e476 | ||
|
|
e215807e56 | ||
|
|
b0333e89cd | ||
|
|
bc240a40e0 | ||
|
|
ed816e4b06 | ||
|
|
7c0f26ed3e | ||
|
|
a54e37a648 | ||
|
|
c0656114b8 | ||
|
|
7628ec7921 | ||
|
|
30850329f3 | ||
|
|
6bb1c4299f | ||
|
|
73c6758537 | ||
|
|
ef1400d3f8 | ||
|
|
494142f026 | ||
|
|
b89deffda3 | ||
|
|
ab55978732 | ||
|
|
b8dcd26e3a | ||
|
|
24f7544c9f | ||
|
|
afb2caa95d | ||
|
|
a684dcced9 | ||
|
|
cf1552910a | ||
|
|
e53e2b36cf | ||
|
|
793e85f889 | ||
|
|
126304bb8a | ||
|
|
7b3b9b4aad | ||
|
|
cf3b8d90e3 | ||
|
|
109b244676 | ||
|
|
9f42333465 | ||
|
|
c9141b0d86 | ||
|
|
d2c4a58528 | ||
|
|
e93d678b97 | ||
|
|
307940d732 | ||
|
|
721f0df82a | ||
|
|
aa87abc53d | ||
|
|
83d4aa0cb1 | ||
|
|
46034aff54 | ||
|
|
3e447ac7e4 | ||
|
|
bda27faaa3 | ||
|
|
80d89a2530 | ||
|
|
e849ef5dfa | ||
|
|
a26ecb91b8 | ||
|
|
2853e13edc | ||
|
|
9ba0b844a1 | ||
|
|
fdab54a055 | ||
|
|
7e0801119e | ||
|
|
b2f7bcaf1e | ||
|
|
71b8be37da | ||
|
|
f7d19e3747 | ||
|
|
17dcbfc344 | ||
|
|
78745de0ca | ||
|
|
35445e2b3d | ||
|
|
bd2f0f6236 | ||
|
|
4c67965b50 | ||
|
|
234e93349b | ||
|
|
e7e20de502 | ||
|
|
dfc36b4581 | ||
|
|
c56e2526c4 | ||
|
|
8ff6bf652c | ||
|
|
a386fe9ba1 | ||
|
|
4d84fc242b | ||
|
|
40e79a3a14 | ||
|
|
c653bb32a7 | ||
|
|
b032e70d7e | ||
|
|
074816be50 | ||
|
|
3fafd5192f | ||
|
|
1d63197b56 | ||
|
|
b2c57e7407 | ||
|
|
581aa51792 | ||
|
|
4d57ece30d | ||
|
|
eddbf07b11 | ||
|
|
450ea063b4 | ||
|
|
f320d84874 | ||
|
|
c832c8e860 | ||
|
|
e5ef8eaf72 | ||
|
|
6db71f525d | ||
|
|
3ab66ef12a | ||
|
|
018f759fa4 | ||
|
|
1afff11063 | ||
|
|
7e3436e68f | ||
|
|
b751f1054b | ||
|
|
900e9e75f3 | ||
|
|
62c28d9f51 | ||
|
|
132ca99f94 | ||
|
|
c309ab430e | ||
|
|
13e21bbcce | ||
|
|
0eb36f0ce1 | ||
|
|
6429f0f064 | ||
|
|
7412ac6fc9 | ||
|
|
e58e3c786d | ||
|
|
93fc1e4eb4 | ||
|
|
cacde26796 | ||
|
|
0a3db92c60 | ||
|
|
8bb0cd5ab5 | ||
|
|
e497dc4e36 | ||
|
|
2689a67eb8 | ||
|
|
3d821043bb | ||
|
|
e69c58e615 | ||
|
|
a21b6f9f4e | ||
|
|
99b8038852 | ||
|
|
ef8ca9f8c6 | ||
|
|
d9186df157 | ||
|
|
aca6bfb0bb | ||
|
|
587fc3a98f | ||
|
|
ab1c67e60e | ||
|
|
e271f43066 | ||
|
|
6bf8feb26e | ||
|
|
ffd66f6a21 | ||
|
|
3b135df4c1 | ||
|
|
4369d04940 | ||
|
|
faaa78fed7 | ||
|
|
6bea1660ea | ||
|
|
8d46676c25 | ||
|
|
4c75e638a2 | ||
|
|
dd73a3803a | ||
|
|
f6c345d7cf | ||
|
|
585b56a668 | ||
|
|
f18f3b4f35 | ||
|
|
eb7871a048 | ||
|
|
000fc78fd3 | ||
|
|
ba676ef956 | ||
|
|
36ea88e2d6 | ||
|
|
5237e6fa50 | ||
|
|
99bde1819c | ||
|
|
f5d7ec2890 | ||
|
|
13c65435d3 | ||
|
|
315420f1a5 | ||
|
|
ab7051f075 | ||
|
|
a43e5bbe9d | ||
|
|
b7bd4541b1 | ||
|
|
648f25e9cc | ||
|
|
ccbe85a46a | ||
|
|
d168d79fe0 | ||
|
|
d37dde2477 | ||
|
|
8e13b07c84 | ||
|
|
927e7724f0 | ||
|
|
6558c5bd69 | ||
|
|
5f7efbb69c | ||
|
|
b79795af50 | ||
|
|
9479806cb0 | ||
|
|
6e49ea78ec | ||
|
|
7b1edd9c54 | ||
|
|
aeaafd2964 | ||
|
|
622fa01602 | ||
|
|
e2b3c1ce8e | ||
|
|
6c5db650e7 | ||
|
|
731072425b | ||
|
|
0f817308a8 | ||
|
|
0fc1e15cac | ||
|
|
acf30384b7 | ||
|
|
d2040eaac9 | ||
|
|
93673fce03 | ||
|
|
d7a432068b | ||
|
|
cb9215980a | ||
|
|
a4fc1f1c6f | ||
|
|
cbbdb11938 | ||
|
|
a2274bca7b | ||
|
|
f84496b09d | ||
|
|
3abf310a3b | ||
|
|
f12e361c2e | ||
|
|
cd0f1e98cc | ||
|
|
325ef80951 | ||
|
|
9a30d7c7da | ||
|
|
25ea75b761 | ||
|
|
32edf77d35 | ||
|
|
47fbb2b1b7 | ||
|
|
e388f81e1f | ||
|
|
f0bea295c4 | ||
|
|
7439ded43d | ||
|
|
6a640d3708 | ||
|
|
776bce9087 | ||
|
|
3c499f9e97 | ||
|
|
114ff7a3e3 | ||
|
|
527cdf523c | ||
|
|
91eb8ab824 | ||
|
|
7a87fb1c2e | ||
|
|
d8cc6b4c22 | ||
|
|
c9bd94d9f8 | ||
|
|
93bf818882 | ||
|
|
723fb3848d | ||
|
|
6a213e2249 | ||
|
|
a6c5c3a317 | ||
|
|
9313d2c8eb | ||
|
|
485a874ab5 | ||
|
|
f2bc884632 | ||
|
|
39d6653f8e | ||
|
|
2ce0fcb264 | ||
|
|
8bf5e18ae5 | ||
|
|
88f4d8074a | ||
|
|
f5aa2fcac8 | ||
|
|
6f892bea6b | ||
|
|
cbf0c9c988 | ||
|
|
393c67213d | ||
|
|
f69de9f071 | ||
|
|
2e400c0d22 | ||
|
|
6035c10550 | ||
|
|
555b156154 | ||
|
|
a0ea2e8910 | ||
|
|
734ca39cbd | ||
|
|
e0e5cfada5 | ||
|
|
7e0c43bc46 | ||
|
|
be1125a9ab | ||
|
|
d21c985a77 | ||
|
|
28f2b9b27e | ||
|
|
9b185e19e9 | ||
|
|
27b923b462 | ||
|
|
357dfee050 | ||
|
|
7f4004c228 | ||
|
|
9b8dc0ed80 | ||
|
|
3cc1286271 | ||
|
|
df281758b7 | ||
|
|
25273c18c8 | ||
|
|
f1be945423 | ||
|
|
9a4f772f53 | ||
|
|
d669e8114b | ||
|
|
3972e3603b | ||
|
|
acc22fcb62 | ||
|
|
2df360d7fb | ||
|
|
46331ed2c6 | ||
|
|
3aee3b0515 | ||
|
|
72c45692b2 | ||
|
|
8edf71ca55 | ||
|
|
612b9e6524 | ||
|
|
7aff65f07b | ||
|
|
5d350fcfad | ||
|
|
5546ad204c | ||
|
|
d66efa0a1d | ||
|
|
36d3d38530 | ||
|
|
8e79141860 | ||
|
|
9b3545f7ca | ||
|
|
56db20faa0 | ||
|
|
b0bd4c9fed | ||
|
|
ba079452e2 | ||
|
|
f0f2b3da4b | ||
|
|
866049543c | ||
|
|
40ed4b8b0e | ||
|
|
b43d08ca67 | ||
|
|
5e7e386108 | ||
|
|
4176df9940 | ||
|
|
de2ef959fe | ||
|
|
b53cfebac1 | ||
|
|
6895b9cc6b | ||
|
|
c60d6e46f1 | ||
|
|
c66d190174 | ||
|
|
5e8da591be | ||
|
|
9c02a6738b | ||
|
|
5ed0184bca | ||
|
|
ae64ca4a93 | ||
|
|
c47099895e | ||
|
|
a2529febba | ||
|
|
521e0ba8b3 | ||
|
|
ee0efac9be | ||
|
|
bfe7635489 | ||
|
|
aa1735f024 | ||
|
|
8deae983c7 | ||
|
|
f349646703 | ||
|
|
5003e80500 | ||
|
|
940d9cd6b5 | ||
|
|
197c166789 | ||
|
|
d114db091e | ||
|
|
3204da8e43 | ||
|
|
100eb14408 | ||
|
|
025017ace5 | ||
|
|
6a690c7c10 | ||
|
|
dd7f77751c | ||
|
|
0c13b8ef1a | ||
|
|
c6ca58ab97 | ||
|
|
0846fc1d96 | ||
|
|
e41dd68ee0 | ||
|
|
0a92996da8 | ||
|
|
082bc6145c | ||
|
|
bf3f16451b | ||
|
|
3cb37003cb | ||
|
|
9acfd2cd06 | ||
|
|
3242e7ebb8 | ||
|
|
7644d628e7 | ||
|
|
b4f19e6de4 | ||
|
|
0388425763 | ||
|
|
ca5d303ac7 | ||
|
|
18e66a92ad | ||
|
|
7d0a56ab98 | ||
|
|
5069792d12 | ||
|
|
c9789458b9 | ||
|
|
777a0d09ed | ||
|
|
4e2ebcc48a | ||
|
|
90fe1d7709 | ||
|
|
1576dd026e | ||
|
|
ee7a64eea9 |
@@ -3,16 +3,11 @@
|
|||||||
"isRoot": true,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"jetbrains.resharper.globaltools": {
|
"jetbrains.resharper.globaltools": {
|
||||||
"version": "2022.1.0",
|
"version": "2025.1.4",
|
||||||
"commands": [
|
"commands": [
|
||||||
"jb"
|
"jb"
|
||||||
]
|
],
|
||||||
},
|
"rollForward": false
|
||||||
"swashbuckle.aspnetcore.cli": {
|
|
||||||
"version": "5.6.2",
|
|
||||||
"commands": [
|
|
||||||
"swagger"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-2
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
[*]
|
[*]
|
||||||
charset=utf-8-bom
|
charset=utf-8
|
||||||
end_of_line=lf
|
end_of_line=lf
|
||||||
trim_trailing_whitespace=false
|
trim_trailing_whitespace=true
|
||||||
insert_final_newline=false
|
insert_final_newline=false
|
||||||
indent_style=space
|
indent_style=space
|
||||||
indent_size=4
|
indent_size=4
|
||||||
@@ -83,3 +83,8 @@ tab_width=4
|
|||||||
[*.yml]
|
[*.yml]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
# disable CA1848: Use the LoggerMessage delegates`
|
||||||
|
dotnet_diagnostic.ca1848.severity = none
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
*.cs text diff=csharp
|
||||||
|
*.cshtml text diff=html
|
||||||
|
*.csx text diff=csharp
|
||||||
|
*.sln text eol=crlf
|
||||||
|
*.csproj text eol=crlf
|
||||||
@@ -33,33 +33,23 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: macos-latest
|
- os: macos-14
|
||||||
kind: macOS
|
kind: macOS
|
||||||
target: osx-x64
|
target: osx-x64
|
||||||
- os: macos-latest
|
- os: macos-14
|
||||||
kind: macOS
|
kind: macOS
|
||||||
target: osx-arm64
|
target: osx-arm64
|
||||||
steps:
|
steps:
|
||||||
- name: Get the sources
|
- name: Get the sources
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Setup .NET Core
|
- name: Setup .NET Core
|
||||||
uses: actions/setup-dotnet@v1
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 6.0.x
|
dotnet-version: 9.0.203
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: '14'
|
|
||||||
|
|
||||||
- name: Cache NPM dependencies
|
|
||||||
uses: bahmutov/npm-install@v1.4.5
|
|
||||||
with:
|
|
||||||
working-directory: ErsatzTV/client-app
|
|
||||||
|
|
||||||
- name: Clean
|
- name: Clean
|
||||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||||
@@ -68,7 +58,7 @@ jobs:
|
|||||||
run: dotnet restore -r "${{ matrix.target}}"
|
run: dotnet restore -r "${{ matrix.target}}"
|
||||||
|
|
||||||
- name: Import Code-Signing Certificates
|
- name: Import Code-Signing Certificates
|
||||||
uses: Apple-Actions/import-codesign-certs@v1
|
uses: Apple-Actions/import-codesign-certs@v2
|
||||||
with:
|
with:
|
||||||
p12-file-base64: ${{ secrets.apple_developer_certificate_p12_base64 }}
|
p12-file-base64: ${{ secrets.apple_developer_certificate_p12_base64 }}
|
||||||
p12-password: ${{ secrets.apple_developer_certificate_password }}
|
p12-password: ${{ secrets.apple_developer_certificate_password }}
|
||||||
@@ -81,7 +71,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
shell: bash
|
shell: bash
|
||||||
run: dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
run: |
|
||||||
|
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||||
|
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||||
|
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||||
|
|
||||||
- name: Bundle
|
- name: Bundle
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -94,7 +87,7 @@ jobs:
|
|||||||
- name: Sign
|
- name: Sign
|
||||||
shell: bash
|
shell: bash
|
||||||
run: scripts/macOS/sign.sh
|
run: scripts/macOS/sign.sh
|
||||||
|
|
||||||
- name: Create DMG
|
- name: Create DMG
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -108,18 +101,16 @@ jobs:
|
|||||||
--icon "ErsatzTV.app" 200 190 \
|
--icon "ErsatzTV.app" 200 190 \
|
||||||
--hide-extension "ErsatzTV.app" \
|
--hide-extension "ErsatzTV.app" \
|
||||||
--app-drop-link 600 185 \
|
--app-drop-link 600 185 \
|
||||||
|
--skip-jenkins \
|
||||||
|
--no-internet-enable \
|
||||||
"ErsatzTV.dmg" \
|
"ErsatzTV.dmg" \
|
||||||
"ErsatzTV.app/"
|
"ErsatzTV.app/"
|
||||||
|
|
||||||
- name: Notarize
|
- name: Notarize
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -o gon.zip -L -s "https://github.com/mitchellh/gon/releases/latest/download/gon_macos.zip"
|
xcrun notarytool submit ErsatzTV.dmg --apple-id "${{ secrets.ac_username }}" --password "${{ secrets.ac_password }}" --team-id 32MB98Q32R --wait
|
||||||
unzip -o -q gon.zip
|
xcrun stapler staple ErsatzTV.dmg
|
||||||
./gon -log-level=debug -log-json ./gon.json
|
|
||||||
env:
|
|
||||||
AC_USERNAME: ${{ secrets.ac_username }}
|
|
||||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -130,6 +121,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Delete old release assets
|
- name: Delete old release assets
|
||||||
uses: mknejp/delete-release-assets@v1
|
uses: mknejp/delete-release-assets@v1
|
||||||
|
if: ${{ inputs.release_tag == 'develop' }}
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.gh_token }}
|
token: ${{ secrets.gh_token }}
|
||||||
tag: ${{ inputs.release_tag }}
|
tag: ${{ inputs.release_tag }}
|
||||||
@@ -140,7 +132,7 @@ jobs:
|
|||||||
- name: Publish
|
- name: Publish
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: false
|
||||||
tag_name: ${{ inputs.release_tag }}
|
tag_name: ${{ inputs.release_tag }}
|
||||||
files: |
|
files: |
|
||||||
${{ env.RELEASE_NAME }}.dmg
|
${{ env.RELEASE_NAME }}.dmg
|
||||||
@@ -158,8 +150,11 @@ jobs:
|
|||||||
target: linux-x64
|
target: linux-x64
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
kind: linux
|
kind: linux
|
||||||
target: linux-arm
|
target: linux-musl-x64
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
|
kind: linux
|
||||||
|
target: linux-arm
|
||||||
|
- os: ubuntu-24.04-arm
|
||||||
kind: linux
|
kind: linux
|
||||||
target: linux-arm64
|
target: linux-arm64
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
@@ -167,24 +162,14 @@ jobs:
|
|||||||
target: win-x64
|
target: win-x64
|
||||||
steps:
|
steps:
|
||||||
- name: Get the sources
|
- name: Get the sources
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup .NET Core
|
- name: Setup .NET Core
|
||||||
uses: actions/setup-dotnet@v1
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 6.0.x
|
dotnet-version: 9.0.203
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: '14'
|
|
||||||
|
|
||||||
- name: Cache NPM dependencies
|
|
||||||
uses: bahmutov/npm-install@v1.4.5
|
|
||||||
with:
|
|
||||||
working-directory: ErsatzTV/client-app
|
|
||||||
|
|
||||||
- name: Clean
|
- name: Clean
|
||||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||||
@@ -192,12 +177,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: dotnet restore -r "${{ matrix.target }}"
|
run: dotnet restore -r "${{ matrix.target }}"
|
||||||
|
|
||||||
- uses: suisei-cn/actions-download-file@v1
|
- uses: suisei-cn/actions-download-file@v1.3.0
|
||||||
if: ${{ matrix.kind == 'windows' }}
|
if: ${{ matrix.kind == 'windows' }}
|
||||||
id: downloadffmpeg
|
id: downloadffmpeg
|
||||||
name: Download ffmpeg
|
name: Download ffmpeg
|
||||||
with:
|
with:
|
||||||
url: "https://github.com/GyanD/codexffmpeg/releases/download/5.0/ffmpeg-5.0-full_build.7z"
|
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-56-gc2184b65d2-win64-gpl-7.1.zip"
|
||||||
target: ffmpeg/
|
target: ffmpeg/
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
@@ -208,11 +193,18 @@ jobs:
|
|||||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||||
|
|
||||||
# Build everything
|
# Build everything
|
||||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||||
|
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||||
|
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||||
|
mkdir "$release_name"
|
||||||
|
mv scanner/* "$release_name/"
|
||||||
|
mv main/* "$release_name/"
|
||||||
|
|
||||||
# Build Windows launcher
|
# Build Windows launcher
|
||||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||||
dotnet publish ErsatzTV-Windows/ErsatzTV-Windows.csproj --framework net6.0-windows --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
|
||||||
|
ls -l ErsatzTV-Windows/target/release
|
||||||
|
mv ErsatzTV-Windows/target/release/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Download ffmpeg
|
# Download ffmpeg
|
||||||
@@ -230,12 +222,10 @@ jobs:
|
|||||||
|
|
||||||
# Delete output directory
|
# Delete output directory
|
||||||
rm -r "$release_name"
|
rm -r "$release_name"
|
||||||
env:
|
|
||||||
AC_USERNAME: ${{ secrets.ac_username }}
|
|
||||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
|
||||||
|
|
||||||
- name: Delete old release assets
|
- name: Delete old release assets
|
||||||
uses: mknejp/delete-release-assets@v1
|
uses: mknejp/delete-release-assets@v1
|
||||||
|
if: ${{ inputs.release_tag == 'develop' }}
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.gh_token }}
|
token: ${{ secrets.gh_token }}
|
||||||
tag: ${{ inputs.release_tag }}
|
tag: ${{ inputs.release_tag }}
|
||||||
@@ -243,10 +233,11 @@ jobs:
|
|||||||
assets: |
|
assets: |
|
||||||
*${{ matrix.target }}.zip
|
*${{ matrix.target }}.zip
|
||||||
*${{ matrix.target }}.tar.gz
|
*${{ matrix.target }}.tar.gz
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: false
|
||||||
tag_name: ${{ inputs.release_tag }}
|
tag_name: ${{ inputs.release_tag }}
|
||||||
files: |
|
files: |
|
||||||
${{ env.RELEASE_NAME }}.zip
|
${{ env.RELEASE_NAME }}.zip
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Get the sources
|
- name: Get the sources
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Extract Docker Tag
|
- name: Extract Docker Tag
|
||||||
@@ -19,14 +19,14 @@ jobs:
|
|||||||
tag=$(git describe --tags --abbrev=0)
|
tag=$(git describe --tags --abbrev=0)
|
||||||
tag2="${tag:1}"
|
tag2="${tag:1}"
|
||||||
short=$(git rev-parse --short HEAD)
|
short=$(git rev-parse --short HEAD)
|
||||||
final="${tag2/beta/$short}"
|
final="${tag2}-${short}"
|
||||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||||
- name: Extract Artifacts Version
|
- name: Extract Artifacts Version
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
tag=$(git describe --tags --abbrev=0)
|
tag=$(git describe --tags --abbrev=0)
|
||||||
short=$(git rev-parse --short HEAD)
|
short=$(git rev-parse --short HEAD)
|
||||||
final="${tag/beta/$short}"
|
final="${tag}-${short}"
|
||||||
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
|
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
|
||||||
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
||||||
outputs:
|
outputs:
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
||||||
info_version: ${{ env.INFO_VERSION }}
|
info_version: ${{ env.INFO_VERSION }}
|
||||||
build_and_upload:
|
build_and_upload:
|
||||||
uses: jasongdove/ersatztv/.github/workflows/artifacts.yml@main
|
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
|
||||||
needs: calculate_version
|
needs: calculate_version
|
||||||
with:
|
with:
|
||||||
release_tag: develop
|
release_tag: develop
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
build_and_push:
|
build_and_push:
|
||||||
uses: jasongdove/ersatztv/.github/workflows/docker.yml@main
|
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||||
needs: calculate_version
|
needs: calculate_version
|
||||||
with:
|
with:
|
||||||
base_version: develop
|
base_version: develop
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build & Publish to Docker Hub
|
name: Build & Publish to Docker Hub
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -22,72 +22,110 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build_and_push:
|
build_and_push:
|
||||||
name: Build & Publish
|
name: Build & Publish
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.os }}
|
||||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: base
|
- name: amd64
|
||||||
|
os: ubuntu-latest
|
||||||
path: ''
|
path: ''
|
||||||
suffix: ''
|
suffix: '-amd64'
|
||||||
qemu: false
|
|
||||||
- name: nvidia
|
|
||||||
path: 'nvidia/'
|
|
||||||
suffix: '-nvidia'
|
|
||||||
qemu: false
|
|
||||||
- name: vaapi
|
|
||||||
path: 'vaapi/'
|
|
||||||
suffix: '-vaapi'
|
|
||||||
qemu: false
|
qemu: false
|
||||||
|
platform: 'linux/amd64'
|
||||||
|
- name: arm32v7
|
||||||
|
os: ubuntu-latest
|
||||||
|
path: 'arm32v7/'
|
||||||
|
suffix: '-arm'
|
||||||
|
qemu: true
|
||||||
|
platform: 'linux/arm/v7'
|
||||||
- name: arm64
|
- name: arm64
|
||||||
|
os: ubuntu-24.04-arm
|
||||||
path: 'arm64/'
|
path: 'arm64/'
|
||||||
suffix: '-arm64'
|
suffix: '-arm64'
|
||||||
qemu: true
|
qemu: true
|
||||||
|
platform: 'linux/arm64'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v3
|
||||||
if: ${{ matrix.qemu == true }}
|
if: ${{ matrix.qemu == true }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v3
|
||||||
id: docker-buildx
|
id: docker-buildx
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.docker_hub_username }}
|
username: ${{ secrets.docker_hub_username }}
|
||||||
password: ${{ secrets.docker_hub_access_token }}
|
password: ${{ secrets.docker_hub_access_token }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Log in to the Container registry
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
registry: ghcr.io
|
||||||
context: .
|
username: ${{ github.actor }}
|
||||||
file: ./docker/${{ matrix.path }}Dockerfile
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
push: true
|
|
||||||
build-args: |
|
|
||||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
|
||||||
tags: |
|
|
||||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
|
||||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
|
||||||
if: ${{ matrix.name != 'arm64' }}
|
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/${{ matrix.path }}Dockerfile
|
file: ./docker/${{ matrix.path }}Dockerfile
|
||||||
push: true
|
push: true
|
||||||
platforms: 'linux/arm64'
|
provenance: false
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
build-args: |
|
build-args: |
|
||||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
||||||
tags: |
|
tags: |
|
||||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||||
if: ${{ matrix.name == 'arm64' }}
|
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||||
|
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||||
|
|
||||||
|
merge_manifests:
|
||||||
|
name: Merge Manifests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build_and_push
|
||||||
|
steps:
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.docker_hub_username }}
|
||||||
|
password: ${{ secrets.docker_hub_access_token }}
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create and push manifests
|
||||||
|
run: |
|
||||||
|
docker manifest create jasongdove/ersatztv:${{ inputs.base_version }} \
|
||||||
|
jasongdove/ersatztv:${{ inputs.base_version }}-amd64 \
|
||||||
|
jasongdove/ersatztv:${{ inputs.base_version }}-arm64 \
|
||||||
|
jasongdove/ersatztv:${{ inputs.base_version }}-arm
|
||||||
|
docker manifest push jasongdove/ersatztv:${{ inputs.base_version }}
|
||||||
|
docker manifest create jasongdove/ersatztv:${{ inputs.tag_version }} \
|
||||||
|
jasongdove/ersatztv:${{ inputs.tag_version }}-amd64 \
|
||||||
|
jasongdove/ersatztv:${{ inputs.tag_version }}-arm64 \
|
||||||
|
jasongdove/ersatztv:${{ inputs.tag_version }}-arm
|
||||||
|
docker manifest push jasongdove/ersatztv:${{ inputs.tag_version }}
|
||||||
|
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }} \
|
||||||
|
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}-amd64 \
|
||||||
|
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}-arm64 \
|
||||||
|
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}-arm
|
||||||
|
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}
|
||||||
|
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }} \
|
||||||
|
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}-amd64 \
|
||||||
|
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}-arm64 \
|
||||||
|
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}-arm
|
||||||
|
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
name: Publish docs via GitHub Pages
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Deploy docs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout master
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Deploy docs
|
|
||||||
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
CUSTOM_DOMAIN: ersatztv.org
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: 'Close stale issues'
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '30 1 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
ascending: true
|
||||||
|
days-before-stale: 120
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-close: 21
|
||||||
|
days-before-pr-close: -1
|
||||||
|
operations-per-run: 500
|
||||||
|
exempt-issue-labels: 'regression,security,roadmap,future,feature,enhancement,confirmed'
|
||||||
|
stale-issue-label: 'stale'
|
||||||
|
stale-issue-message: |-
|
||||||
|
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
|
||||||
|
|
||||||
|
If you have any questions you can use one of several ways to [contact us](https://ersatztv.org).
|
||||||
|
close-issue-message: |-
|
||||||
|
This issue was closed due to inactivity.
|
||||||
+78
-10
@@ -2,20 +2,16 @@
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
jobs:
|
jobs:
|
||||||
build_and_test:
|
build_and_test_windows:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: windows-latest
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ windows-latest, ubuntu-latest, macos-latest ]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Get the sources
|
- name: Get the sources
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup .NET Core
|
- name: Setup .NET Core
|
||||||
uses: actions/setup-dotnet@v1
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 6.0.x
|
dotnet-version: 9.0.203
|
||||||
|
|
||||||
- name: Clean
|
- name: Clean
|
||||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||||
@@ -23,8 +19,80 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Prep project file
|
||||||
|
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build --configuration Release --no-restore
|
run: dotnet build --configuration Release --no-restore
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: dotnet test --no-restore --verbosity normal
|
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||||
|
|
||||||
|
- name: Build Windows
|
||||||
|
run: |
|
||||||
|
cd ErsatzTV-Windows
|
||||||
|
cargo build --release --all-features
|
||||||
|
build_and_test_linux:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
target: linux-x64
|
||||||
|
- os: ubuntu-latest
|
||||||
|
target: linux-musl-x64
|
||||||
|
- os: ubuntu-latest
|
||||||
|
target: linux-arm
|
||||||
|
- os: ubuntu-24.04-arm
|
||||||
|
target: linux-arm64
|
||||||
|
steps:
|
||||||
|
- name: Get the sources
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET Core
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 9.0.203
|
||||||
|
|
||||||
|
- name: Clean
|
||||||
|
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: dotnet restore -p:RestoreEnablePackagePruning=true -r "${{ matrix.target }}"
|
||||||
|
|
||||||
|
- name: Prep project file
|
||||||
|
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build ErsatzTV/ErsatzTV.csproj --runtime "${{ matrix.target }}" --configuration Release --no-restore && dotnet build --configuration Release --no-restore
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||||
|
build_and_test_mac:
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- name: Get the sources
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Setup .NET Core
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 9.0.203
|
||||||
|
|
||||||
|
- name: Clean
|
||||||
|
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Prep project file
|
||||||
|
run: sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build --configuration Release --no-restore
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Get the sources
|
- name: Get the sources
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Extract Docker Tag
|
- name: Extract Docker Tag
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
tag=$(git describe --tags --abbrev=0)
|
tag=$(git describe --tags --abbrev=0)
|
||||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||||
echo "DOCKER_TAG=${tag/-beta/}" >> $GITHUB_ENV
|
echo "DOCKER_TAG=${tag}" >> $GITHUB_ENV
|
||||||
- name: Extract Artifacts Version
|
- name: Extract Artifacts Version
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
||||||
info_version: ${{ env.INFO_VERSION }}
|
info_version: ${{ env.INFO_VERSION }}
|
||||||
build_and_upload:
|
build_and_upload:
|
||||||
uses: jasongdove/ersatztv/.github/workflows/artifacts.yml@main
|
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
|
||||||
needs: calculate_version
|
needs: calculate_version
|
||||||
with:
|
with:
|
||||||
release_tag: ${{ needs.calculate_version.outputs.artifacts_version }}
|
release_tag: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
build_and_push:
|
build_and_push:
|
||||||
uses: jasongdove/ersatztv/.github/workflows/docker.yml@main
|
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||||
needs: calculate_version
|
needs: calculate_version
|
||||||
with:
|
with:
|
||||||
base_version: latest
|
base_version: latest
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
name: Lint VueJS Files on PR Request
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
jobs:
|
|
||||||
vue-lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# Checkout the current repo
|
|
||||||
- name: Checkout current repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
# Setup NodeJS version 14
|
|
||||||
- name: Setup NodeJS V14.x.x
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: '14'
|
|
||||||
# CD into the current client directory and lint and build the client
|
|
||||||
- name: Lint and Build the client
|
|
||||||
run: |
|
|
||||||
cd ./ErsatzTV/client-app/
|
|
||||||
npm ci --no-optional
|
|
||||||
npm run lint
|
|
||||||
npm run build --if-present
|
|
||||||
@@ -40,6 +40,7 @@ msbuild.wrn
|
|||||||
core
|
core
|
||||||
|
|
||||||
scripts/generate-api-sdk/swagger.json
|
scripts/generate-api-sdk/swagger.json
|
||||||
|
scripts/download-test-content.sh
|
||||||
|
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
[submodule "ErsatzTV-macOS"]
|
[submodule "ErsatzTV-macOS"]
|
||||||
path = ErsatzTV-macOS
|
path = ErsatzTV-macOS
|
||||||
url = git@github.com:jasongdove/ErsatzTV-macOS.git
|
url = git@github.com:ErsatzTV/ErsatzTV-macOS.git
|
||||||
|
|||||||
+1347
-105
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<InformationalVersion>develop</InformationalVersion>
|
<InformationalVersion>develop</InformationalVersion>
|
||||||
|
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
|
||||||
Generated
+1035
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "ersatztv_windows"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tray-item = { git = "https://github.com/olback/tray-item-rs" }
|
||||||
|
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
|
||||||
|
process_path = "0.1.4"
|
||||||
|
|
||||||
|
[dependencies.windows]
|
||||||
|
version = "0.43.0"
|
||||||
|
features = [
|
||||||
|
"Win32_System_Console",
|
||||||
|
"Win32_Foundation"
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
windres = "*"
|
||||||
|
static_vcruntime = "2.0"
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>WinExe</OutputType>
|
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
|
||||||
<RootNamespace>ErsatzTV_Windows</RootNamespace>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<ApplicationIcon>Ersatztv.ico</ApplicationIcon>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="Ersatztv.ico">
|
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Update="Program.cs">
|
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
|
||||||
</Compile>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace ErsatzTV_Windows;
|
|
||||||
|
|
||||||
public static class Program
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The main entry point for the application.
|
|
||||||
/// </summary>
|
|
||||||
[STAThread]
|
|
||||||
public static void Main()
|
|
||||||
{
|
|
||||||
ApplicationConfiguration.Initialize();
|
|
||||||
Application.Run(new TrayApplicationContext());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
using ErsatzTV.Core;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using CliWrap;
|
|
||||||
|
|
||||||
namespace ErsatzTV_Windows;
|
|
||||||
|
|
||||||
public class TrayApplicationContext : ApplicationContext
|
|
||||||
{
|
|
||||||
private readonly NotifyIcon _trayIcon;
|
|
||||||
private readonly CancellationTokenSource _tokenSource;
|
|
||||||
|
|
||||||
public TrayApplicationContext()
|
|
||||||
{
|
|
||||||
_trayIcon = new NotifyIcon
|
|
||||||
{
|
|
||||||
Icon = new Icon("./Ersatztv.ico"),
|
|
||||||
ContextMenuStrip = new ContextMenuStrip(),
|
|
||||||
Visible = true
|
|
||||||
};
|
|
||||||
|
|
||||||
_tokenSource = new CancellationTokenSource();
|
|
||||||
|
|
||||||
AddMenuItem("Launch Web UI", LaunchWebUI);
|
|
||||||
AddMenuItem("Show Logs", ShowLogs);
|
|
||||||
_trayIcon.ContextMenuStrip.Items.Add(new ToolStripSeparator());
|
|
||||||
AddMenuItem("Exit", Exit);
|
|
||||||
|
|
||||||
string folder = AppContext.BaseDirectory;
|
|
||||||
string exe = Path.Combine(folder, "ErsatzTV.exe");
|
|
||||||
|
|
||||||
if (File.Exists(exe))
|
|
||||||
{
|
|
||||||
|
|
||||||
Cli.Wrap(exe)
|
|
||||||
.WithWorkingDirectory(folder)
|
|
||||||
.WithValidation(CommandResultValidation.None)
|
|
||||||
.ExecuteAsync(_tokenSource.Token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddMenuItem(string name, EventHandler action)
|
|
||||||
{
|
|
||||||
var item = new ToolStripMenuItem(name);
|
|
||||||
item.Click += action;
|
|
||||||
_trayIcon.ContextMenuStrip.Items.Add(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LaunchWebUI(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
var process = new Process();
|
|
||||||
process.StartInfo.UseShellExecute = true;
|
|
||||||
process.StartInfo.FileName = "http://localhost:8409";
|
|
||||||
process.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowLogs(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(FileSystemLayout.LogsFolder))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(FileSystemLayout.LogsFolder);
|
|
||||||
}
|
|
||||||
|
|
||||||
var process = new Process();
|
|
||||||
process.StartInfo.UseShellExecute = true;
|
|
||||||
process.StartInfo.FileName = FileSystemLayout.LogsFolder;
|
|
||||||
process.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
_tokenSource?.Cancel();
|
|
||||||
base.Dispose(disposing);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Exit(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
// Hide tray icon, otherwise it will remain shown until user mouses over it
|
|
||||||
_trayIcon.Visible = false;
|
|
||||||
Application.Exit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
use windres::Build;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
static_vcruntime::metabuild();
|
||||||
|
Build::new().compile("ersatztv_windows.rc").unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
id ICON "ersatztv.ico"
|
||||||
|
ersatztv-icon ICON "ersatztv.ico"
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#![windows_subsystem = "windows"]
|
||||||
|
|
||||||
|
use special_folder::SpecialFolder;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
use std::process::Child;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use windows::Win32::System::Console;
|
||||||
|
use {std::sync::mpsc, tray_item::TrayItem};
|
||||||
|
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
enum Message {
|
||||||
|
Exit,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut tray = TrayItem::new("ErsatzTV", "ersatztv-icon").unwrap();
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
|
tray.add_menu_item("Launch Web UI", || {
|
||||||
|
let ui_port = env::var("ETV_UI_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|val| val.parse::<u16>().ok())
|
||||||
|
.unwrap_or(8409);
|
||||||
|
|
||||||
|
let _ = Command::new("cmd")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.arg("/C")
|
||||||
|
.arg("start")
|
||||||
|
.arg(format!("http://localhost:{}", ui_port))
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn();
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tray.add_menu_item("Show Logs", || {
|
||||||
|
let path = SpecialFolder::LocalApplicationData
|
||||||
|
.get()
|
||||||
|
.unwrap()
|
||||||
|
.join("ersatztv")
|
||||||
|
.join("logs");
|
||||||
|
match path.to_str() {
|
||||||
|
None => {}
|
||||||
|
Some(folder) => {
|
||||||
|
fs::create_dir_all(folder).unwrap();
|
||||||
|
let _ = Command::new("explorer.exe")
|
||||||
|
.arg(folder)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tray.inner_mut().add_separator().unwrap();
|
||||||
|
|
||||||
|
tray.add_menu_item("Exit", move || {
|
||||||
|
tx.send(Message::Exit).unwrap();
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let path = process_path::get_executable_path();
|
||||||
|
let mut child: Option<Child> = None;
|
||||||
|
match path {
|
||||||
|
None => {}
|
||||||
|
Some(path) => {
|
||||||
|
let etv = path.parent().unwrap().join("ErsatzTV.exe");
|
||||||
|
if etv.exists() {
|
||||||
|
match etv.to_str() {
|
||||||
|
None => {}
|
||||||
|
Some(etv) => {
|
||||||
|
child = Some(
|
||||||
|
Command::new(etv)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match rx.recv() {
|
||||||
|
Ok(Message::Exit) => {
|
||||||
|
match child {
|
||||||
|
None => {}
|
||||||
|
Some(mut child) => {
|
||||||
|
unsafe {
|
||||||
|
if Console::AttachConsole(child.id()) == true
|
||||||
|
{
|
||||||
|
Console::GenerateConsoleCtrlEvent(Console::CTRL_C_EVENT, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.wait().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
Submodule ErsatzTV-macOS updated: 2f3ee16f11...d4dd985fd6
@@ -0,0 +1,5 @@
|
|||||||
|
[*.cs]
|
||||||
|
# disable CA1711: Identifiers should not have incorrect suffix
|
||||||
|
dotnet_diagnostic.ca1711.severity = none
|
||||||
|
# disable CA1848: Use the LoggerMessage delegates
|
||||||
|
dotnet_diagnostic.ca1848.severity = none
|
||||||
@@ -29,12 +29,12 @@ internal static class Mapper
|
|||||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||||
|
|
||||||
return languages
|
return languages
|
||||||
.Distinct()
|
.Map(lang => allCultures.Filter(ci => string.Equals(
|
||||||
.Map(
|
ci.ThreeLetterISOLanguageName,
|
||||||
lang => allCultures.Filter(
|
lang,
|
||||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
|
StringComparison.OrdinalIgnoreCase)))
|
||||||
.Sequence()
|
|
||||||
.Flatten()
|
.Flatten()
|
||||||
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,41 @@
|
|||||||
using ErsatzTV.Application.MediaItems;
|
using ErsatzTV.Application.MediaItems;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using static ErsatzTV.Application.MediaItems.Mapper;
|
using static ErsatzTV.Application.MediaItems.Mapper;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Artists;
|
namespace ErsatzTV.Application.Artists;
|
||||||
|
|
||||||
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
|
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
|
||||||
{
|
{
|
||||||
private readonly IArtistRepository _artistRepository;
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
|
||||||
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
|
public GetAllArtistsHandler(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||||
|
|
||||||
public Task<List<NamedMediaItemViewModel>> Handle(
|
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||||
GetAllArtists request,
|
GetAllArtists request,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken)
|
||||||
_artistRepository.GetAllArtists()
|
{
|
||||||
.Map(
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
list => list.Filter(
|
|
||||||
a => !string.IsNullOrWhiteSpace(
|
List<Artist> allArtists = await dbContext.Artists
|
||||||
a.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => string.Empty))))
|
.AsNoTracking()
|
||||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
.Include(a => a.ArtistMetadata)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return allArtists.Bind(a => ProjectArtist(a)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Option<NamedMediaItemViewModel> ProjectArtist(Artist a)
|
||||||
|
{
|
||||||
|
foreach (ArtistMetadata metadata in a.ArtistMetadata.HeadOrNone())
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||||
|
{
|
||||||
|
return ProjectToViewModel(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<Artist
|
|||||||
async artist =>
|
async artist =>
|
||||||
{
|
{
|
||||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
|
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
|
||||||
List<string> languageCodes = await _searchRepository.GetAllLanguageCodes(mediaCodes);
|
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
|
||||||
return ProjectToViewModel(artist, languageCodes);
|
return ProjectToViewModel(artist, languageCodes);
|
||||||
},
|
},
|
||||||
() => Task.FromResult(Option<ArtistViewModel>.None));
|
() => Task.FromResult(Option<ArtistViewModel>.None));
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Net;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Artworks;
|
||||||
|
|
||||||
|
public record ArtworkContentTypeModel(string Path, string ContentType)
|
||||||
|
{
|
||||||
|
public static readonly ArtworkContentTypeModel None = new(string.Empty, string.Empty);
|
||||||
|
|
||||||
|
public bool IsExternalUrl => Artwork.IsExternalUrl(Path);
|
||||||
|
|
||||||
|
public bool HasContentType => !string.IsNullOrWhiteSpace(ContentType);
|
||||||
|
|
||||||
|
public string UrlWithContentType => string.IsNullOrWhiteSpace(ContentType)
|
||||||
|
? Path
|
||||||
|
: $"{Path}?contentType={WebUtility.UrlEncode(ContentType)}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Artworks;
|
||||||
|
|
||||||
|
public record GetArtwork(int Id) : IRequest<Either<BaseError, Artwork>>;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Artworks;
|
||||||
|
|
||||||
|
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||||
|
: IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory = dbContextFactory;
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, Artwork>> Handle(
|
||||||
|
GetArtwork request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
Option<Artwork> artwork = await dbContext.Artwork
|
||||||
|
.AsNoTracking()
|
||||||
|
.SelectOneAsync(a => a.Id, a => a.Id == request.Id)
|
||||||
|
.MapT(Project);
|
||||||
|
|
||||||
|
return artwork.ToEither(BaseError.New("Artwork not found"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BaseError.New(ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Artwork Project(Artwork artwork) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = artwork.Id,
|
||||||
|
Path = artwork.Path,
|
||||||
|
ArtworkKind = artwork.ArtworkKind
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using ErsatzTV.Core.Domain;
|
using System.Net;
|
||||||
|
using ErsatzTV.Application.Artworks;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
@@ -9,11 +11,22 @@ public record ChannelViewModel(
|
|||||||
string Group,
|
string Group,
|
||||||
string Categories,
|
string Categories,
|
||||||
int FFmpegProfileId,
|
int FFmpegProfileId,
|
||||||
string Logo,
|
ArtworkContentTypeModel Logo,
|
||||||
|
ChannelStreamSelectorMode StreamSelectorMode,
|
||||||
|
string StreamSelector,
|
||||||
string PreferredAudioLanguageCode,
|
string PreferredAudioLanguageCode,
|
||||||
|
string PreferredAudioTitle,
|
||||||
|
ChannelProgressMode ProgressMode,
|
||||||
StreamingMode StreamingMode,
|
StreamingMode StreamingMode,
|
||||||
int? WatermarkId,
|
int? WatermarkId,
|
||||||
int? FallbackFillerId,
|
int? FallbackFillerId,
|
||||||
int PlayoutCount,
|
int PlayoutCount,
|
||||||
string PreferredSubtitleLanguageCode,
|
string PreferredSubtitleLanguageCode,
|
||||||
ChannelSubtitleMode SubtitleMode);
|
ChannelSubtitleMode SubtitleMode,
|
||||||
|
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||||
|
string MusicVideoCreditsTemplate,
|
||||||
|
ChannelSongVideoMode SongVideoMode,
|
||||||
|
ChannelActiveMode ActiveMode)
|
||||||
|
{
|
||||||
|
public string WebEncodedName => WebUtility.UrlEncode(Name);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Application.Artworks;
|
||||||
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public record CreateChannel
|
public record CreateChannel(
|
||||||
(
|
|
||||||
string Name,
|
string Name,
|
||||||
string Number,
|
string Number,
|
||||||
string Group,
|
string Group,
|
||||||
string Categories,
|
string Categories,
|
||||||
int FFmpegProfileId,
|
int FFmpegProfileId,
|
||||||
string Logo,
|
ArtworkContentTypeModel Logo,
|
||||||
|
ChannelStreamSelectorMode StreamSelectorMode,
|
||||||
|
string StreamSelector,
|
||||||
string PreferredAudioLanguageCode,
|
string PreferredAudioLanguageCode,
|
||||||
|
string PreferredAudioTitle,
|
||||||
|
ChannelProgressMode ProgressMode,
|
||||||
StreamingMode StreamingMode,
|
StreamingMode StreamingMode,
|
||||||
int? WatermarkId,
|
int? WatermarkId,
|
||||||
int? FallbackFillerId,
|
int? FallbackFillerId,
|
||||||
string PreferredSubtitleLanguageCode,
|
string PreferredSubtitleLanguageCode,
|
||||||
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
ChannelSubtitleMode SubtitleMode,
|
||||||
|
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||||
|
string MusicVideoCreditsTemplate,
|
||||||
|
ChannelSongVideoMode SongVideoMode,
|
||||||
|
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||||
|
|||||||
@@ -1,111 +1,113 @@
|
|||||||
using System.Globalization;
|
using System.Text.RegularExpressions;
|
||||||
using System.Text.RegularExpressions;
|
using System.Threading.Channels;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Domain.Filler;
|
using ErsatzTV.Core.Domain.Filler;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
using ErsatzTV.Infrastructure.Extensions;
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
|
public class CreateChannelHandler(
|
||||||
|
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
ISearchTargets searchTargets)
|
||||||
|
: IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
|
||||||
|
|
||||||
public CreateChannelHandler(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
|
||||||
|
|
||||||
public async Task<Either<BaseError, CreateChannelResult>> Handle(
|
public async Task<Either<BaseError, CreateChannelResult>> Handle(
|
||||||
CreateChannel request,
|
CreateChannel request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||||
return await LanguageExtensions.Apply(validation, c => PersistChannel(dbContext, c));
|
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||||
{
|
{
|
||||||
await dbContext.Channels.AddAsync(channel);
|
await dbContext.Channels.AddAsync(channel);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
searchTargets.SearchTargetsChanged();
|
||||||
|
await workerChannel.WriteAsync(new RefreshChannelList());
|
||||||
return new CreateChannelResult(channel.Id);
|
return new CreateChannelResult(channel.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
|
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
|
||||||
(ValidateName(request), await ValidateNumber(dbContext, request),
|
(ValidateName(request), await ValidateNumber(dbContext, request),
|
||||||
await FFmpegProfileMustExist(dbContext, request),
|
await FFmpegProfileMustExist(dbContext, request),
|
||||||
ValidatePreferredAudioLanguage(request),
|
|
||||||
ValidatePreferredSubtitleLanguage(request),
|
|
||||||
await WatermarkMustExist(dbContext, request),
|
await WatermarkMustExist(dbContext, request),
|
||||||
await FillerPresetMustExist(dbContext, request))
|
await FillerPresetMustExist(dbContext, request))
|
||||||
.Apply(
|
.Apply((
|
||||||
(
|
name,
|
||||||
name,
|
number,
|
||||||
number,
|
ffmpegProfileId,
|
||||||
ffmpegProfileId,
|
watermarkId,
|
||||||
preferredAudioLanguageCode,
|
fillerPresetId) =>
|
||||||
preferredSubtitleLanguageCode,
|
{
|
||||||
watermarkId,
|
var artwork = new List<Artwork>();
|
||||||
fillerPresetId) =>
|
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
|
||||||
{
|
{
|
||||||
var artwork = new List<Artwork>();
|
string logo = request.Logo.Path;
|
||||||
if (!string.IsNullOrWhiteSpace(request.Logo))
|
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
artwork.Add(
|
logo = logo.Replace("iptv/logos/", string.Empty);
|
||||||
new Artwork
|
|
||||||
{
|
|
||||||
Path = request.Logo,
|
|
||||||
ArtworkKind = ArtworkKind.Logo,
|
|
||||||
DateAdded = DateTime.UtcNow,
|
|
||||||
DateUpdated = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var channel = new Channel(Guid.NewGuid())
|
artwork.Add(
|
||||||
{
|
new Artwork
|
||||||
Name = name,
|
{
|
||||||
Number = number,
|
Path = logo,
|
||||||
Group = request.Group,
|
ArtworkKind = ArtworkKind.Logo,
|
||||||
Categories = request.Categories,
|
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType)
|
||||||
FFmpegProfileId = ffmpegProfileId,
|
? request.Logo.ContentType
|
||||||
StreamingMode = request.StreamingMode,
|
: null,
|
||||||
Artwork = artwork,
|
DateAdded = DateTime.UtcNow,
|
||||||
PreferredAudioLanguageCode = preferredAudioLanguageCode,
|
DateUpdated = DateTime.UtcNow
|
||||||
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
|
});
|
||||||
SubtitleMode = request.SubtitleMode
|
}
|
||||||
};
|
|
||||||
|
|
||||||
foreach (int id in watermarkId)
|
var channel = new Channel(Guid.NewGuid())
|
||||||
{
|
{
|
||||||
channel.WatermarkId = id;
|
Name = name,
|
||||||
}
|
Number = number,
|
||||||
|
Group = request.Group,
|
||||||
|
Categories = request.Categories,
|
||||||
|
FFmpegProfileId = ffmpegProfileId,
|
||||||
|
ProgressMode = request.ProgressMode,
|
||||||
|
StreamingMode = request.StreamingMode,
|
||||||
|
Artwork = artwork,
|
||||||
|
StreamSelectorMode = request.StreamSelectorMode,
|
||||||
|
StreamSelector = request.StreamSelector,
|
||||||
|
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
|
||||||
|
PreferredAudioTitle = request.PreferredAudioTitle,
|
||||||
|
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
|
||||||
|
SubtitleMode = request.SubtitleMode,
|
||||||
|
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
|
||||||
|
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
|
||||||
|
SongVideoMode = request.SongVideoMode,
|
||||||
|
ActiveMode = request.ActiveMode
|
||||||
|
};
|
||||||
|
|
||||||
foreach (int id in fillerPresetId)
|
foreach (int id in watermarkId)
|
||||||
{
|
{
|
||||||
channel.FallbackFillerId = id;
|
channel.WatermarkId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return channel;
|
foreach (int id in fillerPresetId)
|
||||||
});
|
{
|
||||||
|
channel.FallbackFillerId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
});
|
||||||
|
|
||||||
private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
|
private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
|
||||||
createChannel.NotEmpty(c => c.Name)
|
createChannel.NotEmpty(c => c.Name)
|
||||||
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
|
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
|
||||||
|
|
||||||
private static Validation<BaseError, string> ValidatePreferredAudioLanguage(CreateChannel createChannel) =>
|
|
||||||
Optional(createChannel.PreferredAudioLanguageCode ?? string.Empty)
|
|
||||||
.Filter(
|
|
||||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
|
||||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
.ToValidation<BaseError>("Preferred audio language code is invalid");
|
|
||||||
|
|
||||||
private static Validation<BaseError, string> ValidatePreferredSubtitleLanguage(CreateChannel createChannel) =>
|
|
||||||
Optional(createChannel.PreferredSubtitleLanguageCode ?? string.Empty)
|
|
||||||
.Filter(
|
|
||||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
|
||||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
.ToValidation<BaseError>("Preferred subtitle language code is invalid");
|
|
||||||
|
|
||||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
CreateChannel createChannel)
|
CreateChannel createChannel)
|
||||||
@@ -167,8 +169,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
|||||||
.Map(Optional)
|
.Map(Optional)
|
||||||
.Filter(c => c > 0)
|
.Filter(c => c > 0)
|
||||||
.MapT(_ => Optional(createChannel.FallbackFillerId))
|
.MapT(_ => Optional(createChannel.FallbackFillerId))
|
||||||
.Map(
|
.Map(o => o.ToValidation<BaseError>(
|
||||||
o => o.ToValidation<BaseError>(
|
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
|
||||||
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Task>>;
|
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Unit>>;
|
||||||
|
|||||||
@@ -1,23 +1,66 @@
|
|||||||
using ErsatzTV.Core;
|
using System.Threading.Channels;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Task>>
|
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
|
||||||
{
|
{
|
||||||
private readonly IChannelRepository _channelRepository;
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
|
private readonly ISearchTargets _searchTargets;
|
||||||
|
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||||
|
|
||||||
public DeleteChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
public DeleteChannelHandler(
|
||||||
|
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
ILocalFileSystem localFileSystem,
|
||||||
|
ISearchTargets searchTargets)
|
||||||
|
{
|
||||||
|
_workerChannel = workerChannel;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_localFileSystem = localFileSystem;
|
||||||
|
_searchTargets = searchTargets;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Either<BaseError, Task>> Handle(DeleteChannel request, CancellationToken cancellationToken) =>
|
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
|
||||||
(await ChannelMustExist(request))
|
{
|
||||||
.Map(DoDeletion)
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
.ToEither<Task>();
|
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request);
|
||||||
|
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
private Task DoDeletion(int channelId) => _channelRepository.Delete(channelId);
|
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
dbContext.Channels.Remove(channel);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
private async Task<Validation<BaseError, int>> ChannelMustExist(DeleteChannel deleteChannel) =>
|
_searchTargets.SearchTargetsChanged();
|
||||||
(await _channelRepository.Get(deleteChannel.ChannelId))
|
|
||||||
.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.")
|
// delete channel data from channel guide cache
|
||||||
.Map(c => c.Id);
|
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||||
|
if (_localFileSystem.FileExists(cacheFile))
|
||||||
|
{
|
||||||
|
File.Delete(cacheFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh channel list to remove channel that has no playout
|
||||||
|
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||||
|
|
||||||
|
return Unit.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||||
|
TvContext dbContext,
|
||||||
|
DeleteChannel deleteChannel)
|
||||||
|
{
|
||||||
|
Option<Channel> maybeChannel = await dbContext.Channels
|
||||||
|
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId);
|
||||||
|
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public record RefreshChannelData(string ChannelNumber) : IRequest, IBackgroundServiceRequest;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public record RefreshChannelList : IRequest, IBackgroundServiceRequest;
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using System.Net;
|
||||||
|
using System.Xml;
|
||||||
|
using Dapper;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
|
using ErsatzTV.Core.Iptv;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.IO;
|
||||||
|
using Scriban;
|
||||||
|
using Scriban.Runtime;
|
||||||
|
using WebMarkupMin.Core;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
|
private readonly ILogger<RefreshChannelListHandler> _logger;
|
||||||
|
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||||
|
|
||||||
|
public RefreshChannelListHandler(
|
||||||
|
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
ILocalFileSystem localFileSystem,
|
||||||
|
ILogger<RefreshChannelListHandler> logger)
|
||||||
|
{
|
||||||
|
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_localFileSystem = localFileSystem;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||||
|
|
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
|
||||||
|
|
||||||
|
// fall back to default template
|
||||||
|
if (!_localFileSystem.FileExists(templateFileName))
|
||||||
|
{
|
||||||
|
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail if file doesn't exist
|
||||||
|
if (!_localFileSystem.FileExists(templateFileName))
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Unable to generate channel list without template file {File}; please restart ErsatzTV",
|
||||||
|
templateFileName);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var minifier = new XmlMinifier(
|
||||||
|
new XmlMinificationSettings
|
||||||
|
{
|
||||||
|
MinifyWhitespace = true,
|
||||||
|
RemoveXmlComments = true,
|
||||||
|
CollapseTagsWithoutContent = true
|
||||||
|
});
|
||||||
|
|
||||||
|
string text = await File.ReadAllTextAsync(templateFileName, cancellationToken);
|
||||||
|
var template = Template.Parse(text, templateFileName);
|
||||||
|
var templateContext = new XmlTemplateContext();
|
||||||
|
|
||||||
|
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||||
|
await using var xml = XmlWriter.Create(
|
||||||
|
ms,
|
||||||
|
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||||
|
|
||||||
|
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken))
|
||||||
|
{
|
||||||
|
bool hasLogo = !string.IsNullOrWhiteSpace(channel.ArtworkPath);
|
||||||
|
bool hasExternalLogo = hasLogo && Artwork.IsExternalUrl(channel.ArtworkPath);
|
||||||
|
|
||||||
|
var data = new
|
||||||
|
{
|
||||||
|
ChannelId = ChannelIdentifier.FromNumber(channel.Number),
|
||||||
|
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(channel.Number),
|
||||||
|
ChannelNumber = channel.Number,
|
||||||
|
ChannelName = channel.Name,
|
||||||
|
ChannelCategories = GetCategories(channel.Categories),
|
||||||
|
ChannelHasExternalArtwork = hasExternalLogo,
|
||||||
|
ChannelHasArtwork = hasLogo,
|
||||||
|
ChannelArtworkPath = channel.ArtworkPath,
|
||||||
|
ChannelNameEncoded = WebUtility.UrlEncode(channel.Name)
|
||||||
|
};
|
||||||
|
|
||||||
|
var scriptObject = new ScriptObject();
|
||||||
|
scriptObject.Import(data);
|
||||||
|
templateContext.PushGlobal(scriptObject);
|
||||||
|
|
||||||
|
string result = await template.RenderAsync(templateContext);
|
||||||
|
|
||||||
|
MarkupMinificationResult minified = minifier.Minify(result);
|
||||||
|
await xml.WriteRawAsync(minified.MinifiedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
await xml.FlushAsync();
|
||||||
|
|
||||||
|
string tempFile = Path.GetTempFileName();
|
||||||
|
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||||
|
|
||||||
|
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||||
|
File.Move(tempFile, targetFile, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async IAsyncEnumerable<ChannelResult> GetChannels(TvContext dbContext)
|
||||||
|
{
|
||||||
|
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
|
||||||
|
from Channel C
|
||||||
|
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
|
||||||
|
where C.Id in (select ChannelId from Playout) and C.ActiveMode = 0
|
||||||
|
order by CAST(C.Number as double)";
|
||||||
|
// TODO: this needs to be fixed for sqlite/mariadb
|
||||||
|
|
||||||
|
await using var reader = (DbDataReader)await dbContext.Connection.ExecuteReaderAsync(QUERY);
|
||||||
|
Func<DbDataReader, ChannelResult> rowParser = reader.GetRowParser<ChannelResult>();
|
||||||
|
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
yield return rowParser(reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (await reader.NextResultAsync())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetCategories(string categories) =>
|
||||||
|
(categories ?? string.Empty).Split(',')
|
||||||
|
.Map(s => s.Trim())
|
||||||
|
.Filter(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ReSharper disable once ClassNeverInstantiated.Local
|
||||||
|
private sealed record ChannelResult(string Number, string Name, string Categories, string ArtworkPath);
|
||||||
|
}
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Application.Artworks;
|
||||||
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public record UpdateChannel
|
public record UpdateChannel(
|
||||||
(
|
|
||||||
int ChannelId,
|
int ChannelId,
|
||||||
string Name,
|
string Name,
|
||||||
string Number,
|
string Number,
|
||||||
string Group,
|
string Group,
|
||||||
string Categories,
|
string Categories,
|
||||||
int FFmpegProfileId,
|
int FFmpegProfileId,
|
||||||
string Logo,
|
ArtworkContentTypeModel Logo,
|
||||||
|
ChannelStreamSelectorMode StreamSelectorMode,
|
||||||
|
string StreamSelector,
|
||||||
string PreferredAudioLanguageCode,
|
string PreferredAudioLanguageCode,
|
||||||
|
string PreferredAudioTitle,
|
||||||
|
ChannelProgressMode ProgressMode,
|
||||||
StreamingMode StreamingMode,
|
StreamingMode StreamingMode,
|
||||||
int? WatermarkId,
|
int? WatermarkId,
|
||||||
int? FallbackFillerId,
|
int? FallbackFillerId,
|
||||||
string PreferredSubtitleLanguageCode,
|
string PreferredSubtitleLanguageCode,
|
||||||
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
ChannelSubtitleMode SubtitleMode,
|
||||||
|
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||||
|
string MusicVideoCreditsTemplate,
|
||||||
|
ChannelSongVideoMode SongVideoMode,
|
||||||
|
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Globalization;
|
using System.Text.RegularExpressions;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using ErsatzTV.Application.Subtitles;
|
using ErsatzTV.Application.Subtitles;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
using ErsatzTV.Infrastructure.Extensions;
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -12,26 +12,19 @@ using Channel = ErsatzTV.Core.Domain.Channel;
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
public class UpdateChannelHandler(
|
||||||
|
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
ISearchTargets searchTargets)
|
||||||
|
: IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
|
||||||
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
|
|
||||||
|
|
||||||
public UpdateChannelHandler(
|
|
||||||
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel,
|
|
||||||
IDbContextFactory<TvContext> dbContextFactory)
|
|
||||||
{
|
|
||||||
_ffmpegWorkerChannel = ffmpegWorkerChannel;
|
|
||||||
_dbContextFactory = dbContextFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Either<BaseError, ChannelViewModel>> Handle(
|
public async Task<Either<BaseError, ChannelViewModel>> Handle(
|
||||||
UpdateChannel request,
|
UpdateChannel request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||||
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
|
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
|
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
|
||||||
@@ -41,40 +34,72 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
|||||||
c.Group = update.Group;
|
c.Group = update.Group;
|
||||||
c.Categories = update.Categories;
|
c.Categories = update.Categories;
|
||||||
c.FFmpegProfileId = update.FFmpegProfileId;
|
c.FFmpegProfileId = update.FFmpegProfileId;
|
||||||
|
c.StreamSelectorMode = update.StreamSelectorMode;
|
||||||
|
c.StreamSelector = update.StreamSelector;
|
||||||
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
|
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
|
||||||
|
c.PreferredAudioTitle = update.PreferredAudioTitle;
|
||||||
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
|
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
|
||||||
c.SubtitleMode = update.SubtitleMode;
|
c.SubtitleMode = update.SubtitleMode;
|
||||||
c.Artwork ??= new List<Artwork>();
|
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
|
||||||
|
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
|
||||||
|
c.SongVideoMode = update.SongVideoMode;
|
||||||
|
c.ActiveMode = update.ActiveMode;
|
||||||
|
c.Artwork ??= [];
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(update.Logo))
|
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
|
||||||
{
|
{
|
||||||
Option<Artwork> maybeLogo =
|
string logo = update.Logo.Path;
|
||||||
Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo);
|
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
logo = logo.Replace("iptv/logos/", string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
maybeLogo.Match(
|
Option<Artwork> maybeLogo = c.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo).HeadOrNone();
|
||||||
artwork =>
|
foreach (Artwork artwork in maybeLogo)
|
||||||
|
{
|
||||||
|
artwork.Path = logo;
|
||||||
|
artwork.OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
|
||||||
|
? update.Logo.ContentType
|
||||||
|
: null;
|
||||||
|
artwork.DateUpdated = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maybeLogo.IsNone)
|
||||||
|
{
|
||||||
|
var artwork = new Artwork
|
||||||
{
|
{
|
||||||
artwork.Path = update.Logo;
|
Path = logo,
|
||||||
artwork.DateUpdated = DateTime.UtcNow;
|
OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
|
||||||
},
|
? update.Logo.ContentType
|
||||||
() =>
|
: null,
|
||||||
{
|
DateAdded = DateTime.UtcNow,
|
||||||
var artwork = new Artwork
|
DateUpdated = DateTime.UtcNow,
|
||||||
{
|
ArtworkKind = ArtworkKind.Logo
|
||||||
Path = update.Logo,
|
};
|
||||||
DateAdded = DateTime.UtcNow,
|
c.Artwork.Add(artwork);
|
||||||
DateUpdated = DateTime.UtcNow,
|
}
|
||||||
ArtworkKind = ArtworkKind.Logo
|
}
|
||||||
};
|
else
|
||||||
c.Artwork.Add(artwork);
|
{
|
||||||
});
|
await dbContext.Entry(c)
|
||||||
|
.Collection(channel => channel.Artwork)
|
||||||
|
.LoadAsync();
|
||||||
|
|
||||||
|
foreach (Artwork artwork in c.Artwork.Where(x => x.ArtworkKind is ArtworkKind.Logo).ToList())
|
||||||
|
{
|
||||||
|
c.Artwork.Remove(artwork);
|
||||||
|
dbContext.Artwork.Remove(artwork);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.ProgressMode = update.ProgressMode;
|
||||||
c.StreamingMode = update.StreamingMode;
|
c.StreamingMode = update.StreamingMode;
|
||||||
c.WatermarkId = update.WatermarkId;
|
c.WatermarkId = update.WatermarkId;
|
||||||
c.FallbackFillerId = update.FallbackFillerId;
|
c.FallbackFillerId = update.FallbackFillerId;
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
searchTargets.SearchTargetsChanged();
|
||||||
|
|
||||||
if (c.SubtitleMode != ChannelSubtitleMode.None)
|
if (c.SubtitleMode != ChannelSubtitleMode.None)
|
||||||
{
|
{
|
||||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||||
@@ -82,18 +107,19 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
|||||||
|
|
||||||
foreach (Playout playout in maybePlayout)
|
foreach (Playout playout in maybePlayout)
|
||||||
{
|
{
|
||||||
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await workerChannel.WriteAsync(new RefreshChannelList());
|
||||||
|
|
||||||
return ProjectToViewModel(c);
|
return ProjectToViewModel(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
|
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
|
||||||
(await ChannelMustExist(dbContext, request), ValidateName(request),
|
(await ChannelMustExist(dbContext, request), ValidateName(request),
|
||||||
await ValidateNumber(dbContext, request),
|
await ValidateNumber(dbContext, request))
|
||||||
ValidatePreferredAudioLanguage(request))
|
.Apply((channelToUpdate, _, _) => channelToUpdate);
|
||||||
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
|
|
||||||
|
|
||||||
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
@@ -128,11 +154,4 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
|||||||
|
|
||||||
return BaseError.New("Channel number must be unique");
|
return BaseError.New("Channel number must be unique");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Validation<BaseError, string> ValidatePreferredAudioLanguage(UpdateChannel updateChannel) =>
|
|
||||||
Optional(updateChannel.PreferredAudioLanguageCode ?? string.Empty)
|
|
||||||
.Filter(
|
|
||||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
|
||||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
.ToValidation<BaseError>("Preferred audio language code is invalid");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ErsatzTV.Core.Api.Channels;
|
using ErsatzTV.Application.Artworks;
|
||||||
|
using ErsatzTV.Core.Api.Channels;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
@@ -14,13 +15,21 @@ internal static class Mapper
|
|||||||
channel.Categories,
|
channel.Categories,
|
||||||
channel.FFmpegProfileId,
|
channel.FFmpegProfileId,
|
||||||
GetLogo(channel),
|
GetLogo(channel),
|
||||||
|
channel.StreamSelectorMode,
|
||||||
|
channel.StreamSelector,
|
||||||
channel.PreferredAudioLanguageCode,
|
channel.PreferredAudioLanguageCode,
|
||||||
|
channel.PreferredAudioTitle,
|
||||||
|
channel.ProgressMode,
|
||||||
channel.StreamingMode,
|
channel.StreamingMode,
|
||||||
channel.WatermarkId,
|
channel.WatermarkId,
|
||||||
channel.FallbackFillerId,
|
channel.FallbackFillerId,
|
||||||
channel.Playouts?.Count ?? 0,
|
channel.Playouts?.Count ?? 0,
|
||||||
channel.PreferredSubtitleLanguageCode,
|
channel.PreferredSubtitleLanguageCode,
|
||||||
channel.SubtitleMode);
|
channel.SubtitleMode,
|
||||||
|
channel.MusicVideoCreditsMode,
|
||||||
|
channel.MusicVideoCreditsTemplate,
|
||||||
|
channel.SongVideoMode,
|
||||||
|
channel.ActiveMode);
|
||||||
|
|
||||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||||
new(
|
new(
|
||||||
@@ -31,9 +40,27 @@ internal static class Mapper
|
|||||||
channel.PreferredAudioLanguageCode,
|
channel.PreferredAudioLanguageCode,
|
||||||
GetStreamingMode(channel));
|
GetStreamingMode(channel));
|
||||||
|
|
||||||
private static string GetLogo(Channel channel) =>
|
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||||
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
|
new(resolution.Height, resolution.Width);
|
||||||
.Match(a => a.Path, string.Empty);
|
|
||||||
|
internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) =>
|
||||||
|
new(resolution.Height, resolution.Width, bitrate);
|
||||||
|
|
||||||
|
private static ArtworkContentTypeModel GetLogo(Channel channel)
|
||||||
|
{
|
||||||
|
Option<Artwork> maybeArtwork = channel.Artwork
|
||||||
|
.Where(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||||
|
.HeadOrNone();
|
||||||
|
|
||||||
|
foreach (Artwork artwork in maybeArtwork)
|
||||||
|
{
|
||||||
|
return artwork.IsExternalUrl()
|
||||||
|
? new ArtworkContentTypeModel(artwork.Path, string.Empty)
|
||||||
|
: new ArtworkContentTypeModel($"iptv/logos/{artwork.Path}", artwork.OriginalContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ArtworkContentTypeModel.None;
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetStreamingMode(Channel channel) =>
|
private static string GetStreamingMode(Channel channel) =>
|
||||||
channel.StreamingMode switch
|
channel.StreamingMode switch
|
||||||
@@ -42,6 +69,7 @@ internal static class Mapper
|
|||||||
StreamingMode.TransportStreamHybrid => "MPEG-TS",
|
StreamingMode.TransportStreamHybrid => "MPEG-TS",
|
||||||
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
||||||
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
||||||
_ => throw new ArgumentOutOfRangeException()
|
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(channel))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ using static ErsatzTV.Application.Channels.Mapper;
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public class GetChannelByIdHandler : IRequestHandler<GetChannelById, Option<ChannelViewModel>>
|
public class GetChannelByIdHandler(IChannelRepository channelRepository)
|
||||||
|
: IRequestHandler<GetChannelById, Option<ChannelViewModel>>
|
||||||
{
|
{
|
||||||
private readonly IChannelRepository _channelRepository;
|
|
||||||
|
|
||||||
public GetChannelByIdHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
|
||||||
|
|
||||||
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
|
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
|
||||||
_channelRepository.Get(request.Id)
|
channelRepository.GetChannel(request.Id)
|
||||||
.MapT(ProjectToViewModel);
|
.MapT(ProjectToViewModel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,10 +86,20 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
if (distinct.Count != 0)
|
||||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
{
|
||||||
request.ChannelNumber,
|
_logger.LogInformation(
|
||||||
distinct[0]);
|
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||||
|
request.ChannelNumber,
|
||||||
|
distinct[0]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||||
|
request.ChannelNumber);
|
||||||
|
}
|
||||||
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using ErsatzTV.Core.Iptv;
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Iptv;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public record GetChannelGuide(string Scheme, string Host) : IRequest<ChannelGuide>;
|
public record GetChannelGuide(string Scheme, string Host, string BaseUrl, string AccessToken)
|
||||||
|
: IRequest<Either<BaseError, ChannelGuide>>;
|
||||||
|
|||||||
@@ -1,21 +1,87 @@
|
|||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using System.Collections.Immutable;
|
||||||
|
using System.Text;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
using ErsatzTV.Core.Iptv;
|
using ErsatzTV.Core.Iptv;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IO;
|
using Microsoft.IO;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
|
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||||
{
|
{
|
||||||
private readonly IChannelRepository _channelRepository;
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||||
|
|
||||||
public GetChannelGuideHandler(IChannelRepository channelRepository, RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
public GetChannelGuideHandler(
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||||
|
ILocalFileSystem localFileSystem)
|
||||||
{
|
{
|
||||||
_channelRepository = channelRepository;
|
_dbContextFactory = dbContextFactory;
|
||||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||||
|
_localFileSystem = localFileSystem;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
|
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||||
_channelRepository.GetAllForGuide()
|
GetChannelGuide request,
|
||||||
.Map(channels => new ChannelGuide(_recyclableMemoryStreamManager, request.Scheme, request.Host, channels));
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var inactiveChannelNumbers = dbContext.Channels
|
||||||
|
.Where(c => c.ActiveMode != ChannelActiveMode.Active)
|
||||||
|
.Select(c => c.Number)
|
||||||
|
.AsEnumerable()
|
||||||
|
.Select(n => $"{n}.xml")
|
||||||
|
.ToImmutableHashSet();
|
||||||
|
|
||||||
|
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||||
|
if (!_localFileSystem.FileExists(channelsFile))
|
||||||
|
{
|
||||||
|
return BaseError.New($"Required file {channelsFile} is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
|
||||||
|
|
||||||
|
var accessTokenUri = $"?v={mtime}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||||
|
{
|
||||||
|
accessTokenUri += $"&access_token={request.AccessToken}";
|
||||||
|
}
|
||||||
|
|
||||||
|
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||||
|
|
||||||
|
// TODO: is regex faster?
|
||||||
|
channelsFragment = channelsFragment
|
||||||
|
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||||
|
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||||
|
|
||||||
|
var channelDataFragments = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||||
|
{
|
||||||
|
if (fileName.Contains("channels"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inactiveChannelNumbers.Contains(Path.GetFileName(fileName)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||||
|
|
||||||
|
channelDataFragment = channelDataFragment
|
||||||
|
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||||
|
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||||
|
|
||||||
|
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ErsatzTV.Core.Hdhr;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Hdhr;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
@@ -11,5 +12,6 @@ public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<Li
|
|||||||
|
|
||||||
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
|
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
|
||||||
_channelRepository.GetAll()
|
_channelRepository.GetAll()
|
||||||
.Map(channels => channels.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
|
.Map(channels => channels.Where(c => c.ActiveMode is ChannelActiveMode.Active)
|
||||||
|
.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public record GetChannelNameByPlayoutId(int PlayoutId) : IRequest<Option<string>>;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
|
||||||
|
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
return await dbContext.Playouts
|
||||||
|
.Include(p => p.Channel)
|
||||||
|
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
|
||||||
|
.MapT(p => p.Channel.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
using ErsatzTV.Core.Iptv;
|
using ErsatzTV.Core.Iptv;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Channels;
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
public record GetChannelPlaylist(string Scheme, string Host, string Mode) : IRequest<ChannelPlaylist>;
|
public record GetChannelPlaylist(
|
||||||
|
string Scheme,
|
||||||
|
string Host,
|
||||||
|
string BaseUrl,
|
||||||
|
string Mode,
|
||||||
|
string UserAgent,
|
||||||
|
string AccessToken) : IRequest<ChannelPlaylist>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using ErsatzTV.Core.Iptv;
|
using ErsatzTV.Core.Iptv;
|
||||||
|
|
||||||
@@ -14,19 +14,34 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
|||||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||||
_channelRepository.GetAll()
|
_channelRepository.GetAll()
|
||||||
.Map(channels => EnsureMode(channels, request.Mode))
|
.Map(channels => EnsureMode(channels, request.Mode))
|
||||||
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels));
|
.Map(channels => new ChannelPlaylist(
|
||||||
|
request.Scheme,
|
||||||
|
request.Host,
|
||||||
|
request.BaseUrl,
|
||||||
|
channels,
|
||||||
|
request.UserAgent,
|
||||||
|
request.AccessToken));
|
||||||
|
|
||||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||||
{
|
{
|
||||||
var result = new List<Channel>();
|
var result = new List<Channel>();
|
||||||
foreach (Channel channel in channels)
|
foreach (Channel channel in channels)
|
||||||
{
|
{
|
||||||
|
if (channel.ActiveMode is not ChannelActiveMode.Active)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
switch (mode.ToLowerInvariant())
|
switch (mode.ToLowerInvariant())
|
||||||
{
|
{
|
||||||
case "segmenter":
|
case "segmenter":
|
||||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
|
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
|
||||||
result.Add(channel);
|
result.Add(channel);
|
||||||
break;
|
break;
|
||||||
|
case "segmenter-v2":
|
||||||
|
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterV2;
|
||||||
|
result.Add(channel);
|
||||||
|
break;
|
||||||
case "hls-direct":
|
case "hls-direct":
|
||||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
|
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
|
||||||
result.Add(channel);
|
result.Add(channel);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public record GetChannelResolutionAndBitrate(string ChannelNumber) : IRequest<Option<ResolutionAndBitrateViewModel>>;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public class GetChannelResolutionAndBitrateHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||||
|
: IRequestHandler<GetChannelResolutionAndBitrate, Option<ResolutionAndBitrateViewModel>>
|
||||||
|
{
|
||||||
|
public async Task<Option<ResolutionAndBitrateViewModel>> Handle(
|
||||||
|
GetChannelResolutionAndBitrate request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
Option<Channel> maybeChannel = await dbContext.Channels
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(c => c.FFmpegProfile)
|
||||||
|
.ThenInclude(ff => ff.Resolution)
|
||||||
|
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
|
||||||
|
|
||||||
|
return maybeChannel.Map(c => Mapper.ProjectToViewModel(
|
||||||
|
c.FFmpegProfile.Resolution,
|
||||||
|
(int)((c.FFmpegProfile.VideoBitrate * 1000 + c.FFmpegProfile.AudioBitrate * 1000) * 1.2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public record GetChannelStreamSelectors : IRequest<List<string>>;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public class GetChannelStreamSelectorsHandler(ILocalFileSystem localFileSystem)
|
||||||
|
: IRequestHandler<GetChannelStreamSelectors, List<string>>
|
||||||
|
{
|
||||||
|
public Task<List<string>> Handle(GetChannelStreamSelectors request, CancellationToken cancellationToken) =>
|
||||||
|
localFileSystem.ListFiles(FileSystemLayout.ChannelStreamSelectorsFolder)
|
||||||
|
.Map(Path.GetFileName)
|
||||||
|
.ToList()
|
||||||
|
.AsTask();
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public record ResolutionAndBitrateViewModel(int Height, int Width, int Bitrate);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public record ResolutionViewModel(int Height, int Width);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Scriban;
|
||||||
|
using Scriban.Parsing;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Channels;
|
||||||
|
|
||||||
|
public class XmlTemplateContext : TemplateContext
|
||||||
|
{
|
||||||
|
public override TemplateContext Write(SourceSpan span, object textAsObject)
|
||||||
|
=> base.Write(span, textAsObject is string text ? WebUtility.HtmlEncode(text) : textAsObject);
|
||||||
|
|
||||||
|
public override ValueTask<TemplateContext> WriteAsync(SourceSpan span, object textAsObject)
|
||||||
|
=> base.WriteAsync(span, textAsObject is string text ? WebUtility.HtmlEncode(text) : textAsObject);
|
||||||
|
}
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.Configuration;
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest<Unit>;
|
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest;
|
||||||
|
|||||||
@@ -2,16 +2,13 @@
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.Configuration;
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey, Unit>
|
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey>
|
||||||
{
|
{
|
||||||
private readonly IConfigElementRepository _configElementRepository;
|
private readonly IConfigElementRepository _configElementRepository;
|
||||||
|
|
||||||
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
|
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
|
||||||
_configElementRepository = configElementRepository;
|
_configElementRepository = configElementRepository;
|
||||||
|
|
||||||
public async Task<Unit> Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
|
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken) =>
|
||||||
{
|
|
||||||
await _configElementRepository.Upsert(request.Key, request.Value);
|
await _configElementRepository.Upsert(request.Key, request.Value);
|
||||||
return Unit.Default;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,15 @@ public class UpdateLibraryRefreshIntervalHandler :
|
|||||||
UpdateLibraryRefreshInterval request,
|
UpdateLibraryRefreshInterval request,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
Validate(request)
|
Validate(request)
|
||||||
.MapT(
|
.MapT(_ => _configElementRepository.Upsert(
|
||||||
_ => _configElementRepository.Upsert(
|
ConfigElementKey.LibraryRefreshInterval,
|
||||||
ConfigElementKey.LibraryRefreshInterval,
|
request.LibraryRefreshInterval))
|
||||||
request.LibraryRefreshInterval))
|
|
||||||
.Bind(v => v.ToEitherAsync());
|
.Bind(v => v.ToEitherAsync());
|
||||||
|
|
||||||
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
|
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
|
||||||
Optional(request.LibraryRefreshInterval)
|
Optional(request.LibraryRefreshInterval)
|
||||||
.Where(lri => lri > 0)
|
.Where(lri => lri is >= 0 and < 1_000_000)
|
||||||
.Map(_ => Unit.Default)
|
.Map(_ => Unit.Default)
|
||||||
.ToValidation<BaseError>("Tuner count must be greater than zero")
|
.ToValidation<BaseError>("Library refresh interval must be zero or greated")
|
||||||
.AsTask();
|
.AsTask();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public record UpdateLoggingSettings(LoggingSettingsViewModel LoggingSettings) : IRequest<Either<BaseError, Unit>>;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public class UpdateLoggingSettingsHandler : IRequestHandler<UpdateLoggingSettings, Either<BaseError, Unit>>
|
||||||
|
{
|
||||||
|
private readonly IConfigElementRepository _configElementRepository;
|
||||||
|
private readonly LoggingLevelSwitches _loggingLevelSwitches;
|
||||||
|
|
||||||
|
public UpdateLoggingSettingsHandler(
|
||||||
|
LoggingLevelSwitches loggingLevelSwitches,
|
||||||
|
IConfigElementRepository configElementRepository)
|
||||||
|
{
|
||||||
|
_loggingLevelSwitches = loggingLevelSwitches;
|
||||||
|
_configElementRepository = configElementRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>> Handle(
|
||||||
|
UpdateLoggingSettings request,
|
||||||
|
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings);
|
||||||
|
|
||||||
|
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings)
|
||||||
|
{
|
||||||
|
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel);
|
||||||
|
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = loggingSettings.DefaultMinimumLogLevel;
|
||||||
|
|
||||||
|
await _configElementRepository.Upsert(
|
||||||
|
ConfigElementKey.MinimumLogLevelScanning,
|
||||||
|
loggingSettings.ScanningMinimumLogLevel);
|
||||||
|
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = loggingSettings.ScanningMinimumLogLevel;
|
||||||
|
|
||||||
|
await _configElementRepository.Upsert(
|
||||||
|
ConfigElementKey.MinimumLogLevelScheduling,
|
||||||
|
loggingSettings.SchedulingMinimumLogLevel);
|
||||||
|
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = loggingSettings.SchedulingMinimumLogLevel;
|
||||||
|
|
||||||
|
await _configElementRepository.Upsert(
|
||||||
|
ConfigElementKey.MinimumLogLevelStreaming,
|
||||||
|
loggingSettings.StreamingMinimumLogLevel);
|
||||||
|
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = loggingSettings.StreamingMinimumLogLevel;
|
||||||
|
|
||||||
|
await _configElementRepository.Upsert(
|
||||||
|
ConfigElementKey.MinimumLogLevelHttp,
|
||||||
|
loggingSettings.HttpMinimumLogLevel);
|
||||||
|
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = loggingSettings.HttpMinimumLogLevel;
|
||||||
|
|
||||||
|
return Unit.Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading.Channels;
|
using System.Globalization;
|
||||||
|
using System.Threading.Channels;
|
||||||
using ErsatzTV.Application.Playouts;
|
using ErsatzTV.Application.Playouts;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
@@ -45,7 +46,8 @@ public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSetting
|
|||||||
List<Playout> playouts = await dbContext.Playouts
|
List<Playout> playouts = await dbContext.Playouts
|
||||||
.Include(p => p.Channel)
|
.Include(p => p.Channel)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
|
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number, CultureInfo.InvariantCulture))
|
||||||
|
.Map(p => p.Id))
|
||||||
{
|
{
|
||||||
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue));
|
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public record UpdateXmltvSettings(XmltvSettingsViewModel XmltvSettings) : IRequest<Either<BaseError, Unit>>;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using ErsatzTV.Application.Channels;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public class UpdateXmltvSettingsHandler(
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
IConfigElementRepository configElementRepository,
|
||||||
|
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||||
|
: IRequestHandler<UpdateXmltvSettings, Either<BaseError, Unit>>
|
||||||
|
{
|
||||||
|
public async Task<Either<BaseError, Unit>> Handle(
|
||||||
|
UpdateXmltvSettings request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int playoutDaysToBuild =
|
||||||
|
await configElementRepository
|
||||||
|
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
|
||||||
|
.IfNoneAsync(2);
|
||||||
|
|
||||||
|
if (playoutDaysToBuild < request.XmltvSettings.DaysToBuild)
|
||||||
|
{
|
||||||
|
return BaseError.New(
|
||||||
|
$"XMLTV days to build ({request.XmltvSettings.DaysToBuild}) cannot be greater than Playout days to build ({playoutDaysToBuild})");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ApplyUpdate(request.XmltvSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings)
|
||||||
|
{
|
||||||
|
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone);
|
||||||
|
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild);
|
||||||
|
|
||||||
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync())
|
||||||
|
{
|
||||||
|
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Unit.Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public class LoggingSettingsViewModel
|
||||||
|
{
|
||||||
|
public LogEventLevel DefaultMinimumLogLevel { get; set; }
|
||||||
|
public LogEventLevel ScanningMinimumLogLevel { get; set; }
|
||||||
|
public LogEventLevel SchedulingMinimumLogLevel { get; set; }
|
||||||
|
public LogEventLevel StreamingMinimumLogLevel { get; set; }
|
||||||
|
public LogEventLevel HttpMinimumLogLevel { get; set; }
|
||||||
|
}
|
||||||
@@ -13,5 +13,5 @@ public class GetConfigElementByKeyHandler : IRequestHandler<GetConfigElementByKe
|
|||||||
public Task<Option<ConfigElementViewModel>> Handle(
|
public Task<Option<ConfigElementViewModel>> Handle(
|
||||||
GetConfigElementByKey request,
|
GetConfigElementByKey request,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
_configElementRepository.Get(request.Key).MapT(ProjectToViewModel);
|
_configElementRepository.GetConfigElement(request.Key).MapT(ProjectToViewModel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public record GetLoggingSettings : IRequest<LoggingSettingsViewModel>;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public class GetLoggingSettingsHandler : IRequestHandler<GetLoggingSettings, LoggingSettingsViewModel>
|
||||||
|
{
|
||||||
|
private readonly IConfigElementRepository _configElementRepository;
|
||||||
|
|
||||||
|
public GetLoggingSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||||
|
_configElementRepository = configElementRepository;
|
||||||
|
|
||||||
|
public async Task<LoggingSettingsViewModel> Handle(GetLoggingSettings request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Option<LogEventLevel> maybeDefaultLevel =
|
||||||
|
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
|
||||||
|
|
||||||
|
Option<LogEventLevel> maybeScanningLevel =
|
||||||
|
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScanning);
|
||||||
|
|
||||||
|
Option<LogEventLevel> maybeSchedulingLevel =
|
||||||
|
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScheduling);
|
||||||
|
|
||||||
|
Option<LogEventLevel> maybeStreamingLevel =
|
||||||
|
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelStreaming);
|
||||||
|
|
||||||
|
Option<LogEventLevel> maybeHttpLevel =
|
||||||
|
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelHttp);
|
||||||
|
|
||||||
|
return new LoggingSettingsViewModel
|
||||||
|
{
|
||||||
|
DefaultMinimumLogLevel = await maybeDefaultLevel.IfNoneAsync(LogEventLevel.Information),
|
||||||
|
ScanningMinimumLogLevel = await maybeScanningLevel.IfNoneAsync(LogEventLevel.Information),
|
||||||
|
SchedulingMinimumLogLevel = await maybeSchedulingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||||
|
StreamingMinimumLogLevel = await maybeStreamingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||||
|
HttpMinimumLogLevel = await maybeHttpLevel.IfNoneAsync(LogEventLevel.Information)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public record GetXmltvSettings : IRequest<XmltvSettingsViewModel>;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public class GetXmltvSettingsHandler(IConfigElementRepository configElementRepository)
|
||||||
|
: IRequestHandler<GetXmltvSettings, XmltvSettingsViewModel>
|
||||||
|
{
|
||||||
|
public async Task<XmltvSettingsViewModel> Handle(GetXmltvSettings request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Option<int> daysToBuild = await configElementRepository.GetValue<int>(ConfigElementKey.XmltvDaysToBuild);
|
||||||
|
|
||||||
|
Option<XmltvTimeZone> maybeTimeZone =
|
||||||
|
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone);
|
||||||
|
|
||||||
|
return new XmltvSettingsViewModel
|
||||||
|
{
|
||||||
|
DaysToBuild = await daysToBuild.IfNoneAsync(2),
|
||||||
|
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public class XmltvSettingsViewModel
|
||||||
|
{
|
||||||
|
public int DaysToBuild { get; set; }
|
||||||
|
public XmltvTimeZone TimeZone { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ErsatzTV.Application.Configuration;
|
||||||
|
|
||||||
|
public enum XmltvTimeZone
|
||||||
|
{
|
||||||
|
Local = 0,
|
||||||
|
Utc = 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using ErsatzTV.Application.Libraries;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Errors;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.FFmpeg.Runtime;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Emby;
|
||||||
|
|
||||||
|
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
|
||||||
|
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
|
||||||
|
{
|
||||||
|
public CallEmbyCollectionScannerHandler(
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
IConfigElementRepository configElementRepository,
|
||||||
|
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||||
|
IMediator mediator,
|
||||||
|
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>>
|
||||||
|
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Validation<BaseError, string> validation = await Validate(request);
|
||||||
|
return await validation.Match(
|
||||||
|
scanner => PerformScan(scanner, request, cancellationToken),
|
||||||
|
error =>
|
||||||
|
{
|
||||||
|
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||||
|
{
|
||||||
|
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeEmbyCollections request)
|
||||||
|
{
|
||||||
|
DateTime minDateTime = await dbContext.EmbyMediaSources
|
||||||
|
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId)
|
||||||
|
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||||
|
|
||||||
|
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override bool ScanIsRequired(
|
||||||
|
DateTimeOffset lastScan,
|
||||||
|
int libraryRefreshInterval,
|
||||||
|
SynchronizeEmbyCollections request)
|
||||||
|
{
|
||||||
|
if (lastScan == SystemTime.MaxValueUtc)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||||
|
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||||
|
string scanner,
|
||||||
|
SynchronizeEmbyCollections request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var arguments = new List<string>
|
||||||
|
{
|
||||||
|
"scan-emby-collections", request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.ForceScan)
|
||||||
|
{
|
||||||
|
arguments.Add("--force");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using ErsatzTV.Application.Libraries;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Errors;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.FFmpeg.Runtime;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Emby;
|
||||||
|
|
||||||
|
public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeEmbyLibraryById>,
|
||||||
|
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
|
||||||
|
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||||
|
{
|
||||||
|
public CallEmbyLibraryScannerHandler(
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
IConfigElementRepository configElementRepository,
|
||||||
|
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||||
|
IMediator mediator,
|
||||||
|
IRuntimeInfo runtimeInfo)
|
||||||
|
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle(
|
||||||
|
ForceSynchronizeEmbyLibraryById request,
|
||||||
|
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||||
|
|
||||||
|
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>.
|
||||||
|
Handle(
|
||||||
|
SynchronizeEmbyLibraryByIdIfNeeded request,
|
||||||
|
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<Either<BaseError, string>> Handle(
|
||||||
|
ISynchronizeEmbyLibraryById request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Validation<BaseError, string> validation = await Validate(request);
|
||||||
|
return await validation.Match(
|
||||||
|
scanner => PerformScan(scanner, request, cancellationToken),
|
||||||
|
error =>
|
||||||
|
{
|
||||||
|
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||||
|
{
|
||||||
|
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Either<BaseError, string>> PerformScan(
|
||||||
|
string scanner,
|
||||||
|
ISynchronizeEmbyLibraryById request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var arguments = new List<string>
|
||||||
|
{
|
||||||
|
"scan-emby", request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.ForceScan)
|
||||||
|
{
|
||||||
|
arguments.Add("--force");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.DeepScan)
|
||||||
|
{
|
||||||
|
arguments.Add("--deep");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<DateTimeOffset> GetLastScan(
|
||||||
|
TvContext dbContext,
|
||||||
|
ISynchronizeEmbyLibraryById request)
|
||||||
|
{
|
||||||
|
DateTime minDateTime = await dbContext.EmbyLibraries
|
||||||
|
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
|
||||||
|
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||||
|
|
||||||
|
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override bool ScanIsRequired(
|
||||||
|
DateTimeOffset lastScan,
|
||||||
|
int libraryRefreshInterval,
|
||||||
|
ISynchronizeEmbyLibraryById request)
|
||||||
|
{
|
||||||
|
if (lastScan == SystemTime.MaxValueUtc)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||||
|
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,5 +52,5 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private record Parameters(EmbySecrets Secrets, EmbyServerInformation ServerInformation);
|
private sealed record Parameters(EmbySecrets Secrets, EmbyServerInformation ServerInformation);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Emby;
|
namespace ErsatzTV.Application.Emby;
|
||||||
|
|
||||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||||
IEmbyBackgroundServiceRequest;
|
IScannerBackgroundServiceRequest;
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
using ErsatzTV.Core;
|
|
||||||
using ErsatzTV.Core.Domain;
|
|
||||||
using ErsatzTV.Core.Emby;
|
|
||||||
using ErsatzTV.Core.Interfaces.Emby;
|
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Emby;
|
|
||||||
|
|
||||||
public class SynchronizeEmbyCollectionsHandler : IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
|
|
||||||
{
|
|
||||||
private readonly IEmbySecretStore _embySecretStore;
|
|
||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
|
||||||
private readonly IEmbyCollectionScanner _scanner;
|
|
||||||
|
|
||||||
public SynchronizeEmbyCollectionsHandler(
|
|
||||||
IMediaSourceRepository mediaSourceRepository,
|
|
||||||
IEmbySecretStore embySecretStore,
|
|
||||||
IEmbyCollectionScanner scanner)
|
|
||||||
{
|
|
||||||
_mediaSourceRepository = mediaSourceRepository;
|
|
||||||
_embySecretStore = embySecretStore;
|
|
||||||
_scanner = scanner;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public async Task<Either<BaseError, Unit>> Handle(
|
|
||||||
SynchronizeEmbyCollections request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
Validation<BaseError, ConnectionParameters> validation = await Validate(request);
|
|
||||||
return await validation.Match(
|
|
||||||
SynchronizeCollections,
|
|
||||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeEmbyCollections request) =>
|
|
||||||
MediaSourceMustExist(request)
|
|
||||||
.BindT(MediaSourceMustHaveActiveConnection)
|
|
||||||
.BindT(MediaSourceMustHaveApiKey);
|
|
||||||
|
|
||||||
private Task<Validation<BaseError, EmbyMediaSource>> MediaSourceMustExist(
|
|
||||||
SynchronizeEmbyCollections request) =>
|
|
||||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
|
|
||||||
.Map(o => o.ToValidation<BaseError>("Emby media source does not exist."));
|
|
||||||
|
|
||||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
|
||||||
EmbyMediaSource embyMediaSource)
|
|
||||||
{
|
|
||||||
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.HeadOrNone();
|
|
||||||
return maybeConnection.Map(connection => new ConnectionParameters(connection))
|
|
||||||
.ToValidation<BaseError>("Emby media source requires an active connection");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
|
||||||
ConnectionParameters connectionParameters)
|
|
||||||
{
|
|
||||||
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
|
|
||||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
|
||||||
.Where(match => match)
|
|
||||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
|
||||||
.ToValidation<BaseError>("Emby media source requires an api key");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Either<BaseError, Unit>> SynchronizeCollections(ConnectionParameters connectionParameters) =>
|
|
||||||
await _scanner.ScanCollections(
|
|
||||||
connectionParameters.ActiveConnection.Address,
|
|
||||||
connectionParameters.ApiKey);
|
|
||||||
|
|
||||||
private record ConnectionParameters(EmbyConnection ActiveConnection)
|
|
||||||
{
|
|
||||||
public string ApiKey { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
namespace ErsatzTV.Application.Emby;
|
namespace ErsatzTV.Application.Emby;
|
||||||
|
|
||||||
public record SynchronizeEmbyLibraries(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
public record SynchronizeEmbyLibraries(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||||
IEmbyBackgroundServiceRequest;
|
IScannerBackgroundServiceRequest;
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
|||||||
toAdd,
|
toAdd,
|
||||||
toRemove,
|
toRemove,
|
||||||
toUpdate);
|
toUpdate);
|
||||||
if (ids.Any())
|
if (ids.Count != 0)
|
||||||
{
|
{
|
||||||
await _searchIndex.RemoveItems(ids);
|
await _searchIndex.RemoveItems(ids);
|
||||||
_searchIndex.Commit();
|
_searchIndex.Commit();
|
||||||
@@ -102,9 +102,7 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private record ConnectionParameters(
|
private sealed record ConnectionParameters(EmbyMediaSource EmbyMediaSource, EmbyConnection ActiveConnection)
|
||||||
EmbyMediaSource EmbyMediaSource,
|
|
||||||
EmbyConnection ActiveConnection)
|
|
||||||
{
|
{
|
||||||
public string ApiKey { get; set; }
|
public string ApiKey { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.Emby;
|
namespace ErsatzTV.Application.Emby;
|
||||||
|
|
||||||
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>,
|
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest
|
||||||
IEmbyBackgroundServiceRequest
|
|
||||||
{
|
{
|
||||||
int EmbyLibraryId { get; }
|
int EmbyLibraryId { get; }
|
||||||
bool ForceScan { get; }
|
bool ForceScan { get; }
|
||||||
|
bool DeepScan { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SynchronizeEmbyLibraryByIdIfNeeded(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
|
public record SynchronizeEmbyLibraryByIdIfNeeded(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
|
||||||
{
|
{
|
||||||
public bool ForceScan => false;
|
public bool ForceScan => false;
|
||||||
|
public bool DeepScan => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
|
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId, bool DeepScan) : ISynchronizeEmbyLibraryById
|
||||||
{
|
{
|
||||||
public bool ForceScan => true;
|
public bool ForceScan => true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
using System.Threading.Channels;
|
|
||||||
using ErsatzTV.Core;
|
|
||||||
using ErsatzTV.Core.Domain;
|
|
||||||
using ErsatzTV.Core.Emby;
|
|
||||||
using ErsatzTV.Core.Interfaces.Emby;
|
|
||||||
using ErsatzTV.Core.Interfaces.Locking;
|
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Emby;
|
|
||||||
|
|
||||||
public class SynchronizeEmbyLibraryByIdHandler :
|
|
||||||
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
|
|
||||||
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
|
|
||||||
{
|
|
||||||
private readonly IConfigElementRepository _configElementRepository;
|
|
||||||
private readonly IEmbyMovieLibraryScanner _embyMovieLibraryScanner;
|
|
||||||
|
|
||||||
private readonly IEmbySecretStore _embySecretStore;
|
|
||||||
private readonly IEmbyTelevisionLibraryScanner _embyTelevisionLibraryScanner;
|
|
||||||
private readonly ChannelWriter<IEmbyBackgroundServiceRequest> _embyWorkerChannel;
|
|
||||||
private readonly IEntityLocker _entityLocker;
|
|
||||||
private readonly ILibraryRepository _libraryRepository;
|
|
||||||
private readonly ILogger<SynchronizeEmbyLibraryByIdHandler> _logger;
|
|
||||||
|
|
||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
|
||||||
|
|
||||||
public SynchronizeEmbyLibraryByIdHandler(
|
|
||||||
IMediaSourceRepository mediaSourceRepository,
|
|
||||||
IEmbySecretStore embySecretStore,
|
|
||||||
IEmbyMovieLibraryScanner embyMovieLibraryScanner,
|
|
||||||
IEmbyTelevisionLibraryScanner embyTelevisionLibraryScanner,
|
|
||||||
ILibraryRepository libraryRepository,
|
|
||||||
IEntityLocker entityLocker,
|
|
||||||
IConfigElementRepository configElementRepository,
|
|
||||||
ChannelWriter<IEmbyBackgroundServiceRequest> embyWorkerChannel,
|
|
||||||
ILogger<SynchronizeEmbyLibraryByIdHandler> logger)
|
|
||||||
{
|
|
||||||
_mediaSourceRepository = mediaSourceRepository;
|
|
||||||
_embySecretStore = embySecretStore;
|
|
||||||
_embyMovieLibraryScanner = embyMovieLibraryScanner;
|
|
||||||
_embyTelevisionLibraryScanner = embyTelevisionLibraryScanner;
|
|
||||||
_libraryRepository = libraryRepository;
|
|
||||||
_entityLocker = entityLocker;
|
|
||||||
_configElementRepository = configElementRepository;
|
|
||||||
_embyWorkerChannel = embyWorkerChannel;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<Either<BaseError, string>> Handle(
|
|
||||||
ForceSynchronizeEmbyLibraryById request,
|
|
||||||
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
|
|
||||||
|
|
||||||
public Task<Either<BaseError, string>> Handle(
|
|
||||||
SynchronizeEmbyLibraryByIdIfNeeded request,
|
|
||||||
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
|
|
||||||
|
|
||||||
private async Task<Either<BaseError, string>>
|
|
||||||
HandleImpl(ISynchronizeEmbyLibraryById request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
Validation<BaseError, RequestParameters> validation = await Validate(request);
|
|
||||||
return await validation.Match(
|
|
||||||
parameters => Synchronize(parameters, cancellationToken),
|
|
||||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Either<BaseError, string>> Synchronize(
|
|
||||||
RequestParameters parameters,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
|
||||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
|
||||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
|
||||||
{
|
|
||||||
Either<BaseError, Unit> result = parameters.Library.MediaKind switch
|
|
||||||
{
|
|
||||||
LibraryMediaKind.Movies =>
|
|
||||||
await _embyMovieLibraryScanner.ScanLibrary(
|
|
||||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
|
||||||
parameters.ConnectionParameters.ApiKey,
|
|
||||||
parameters.Library,
|
|
||||||
parameters.FFmpegPath,
|
|
||||||
parameters.FFprobePath,
|
|
||||||
cancellationToken),
|
|
||||||
LibraryMediaKind.Shows =>
|
|
||||||
await _embyTelevisionLibraryScanner.ScanLibrary(
|
|
||||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
|
||||||
parameters.ConnectionParameters.ApiKey,
|
|
||||||
parameters.Library,
|
|
||||||
parameters.FFmpegPath,
|
|
||||||
parameters.FFprobePath,
|
|
||||||
cancellationToken),
|
|
||||||
_ => Unit.Default
|
|
||||||
};
|
|
||||||
|
|
||||||
if (result.IsRight)
|
|
||||||
{
|
|
||||||
parameters.Library.LastScan = DateTime.UtcNow;
|
|
||||||
await _libraryRepository.UpdateLastScan(parameters.Library);
|
|
||||||
|
|
||||||
await _embyWorkerChannel.WriteAsync(
|
|
||||||
new SynchronizeEmbyCollections(parameters.Library.MediaSourceId),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Map(_ => parameters.Library.Name);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Skipping unforced scan of emby media library {Name}", parameters.Library.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parameters.Library.Name;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_entityLocker.UnlockLibrary(parameters.Library.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
|
||||||
ISynchronizeEmbyLibraryById request) =>
|
|
||||||
(await ValidateConnection(request), await EmbyLibraryMustExist(request),
|
|
||||||
await ValidateLibraryRefreshInterval(), await ValidateFFmpegPath(), await ValidateFFprobePath())
|
|
||||||
.Apply(
|
|
||||||
(connectionParameters, embyLibrary, libraryRefreshInterval, ffmpegPath, ffprobePath) =>
|
|
||||||
new RequestParameters(
|
|
||||||
connectionParameters,
|
|
||||||
embyLibrary,
|
|
||||||
request.ForceScan,
|
|
||||||
libraryRefreshInterval,
|
|
||||||
ffmpegPath,
|
|
||||||
ffprobePath
|
|
||||||
));
|
|
||||||
|
|
||||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
|
|
||||||
ISynchronizeEmbyLibraryById request) =>
|
|
||||||
EmbyMediaSourceMustExist(request)
|
|
||||||
.BindT(MediaSourceMustHaveActiveConnection)
|
|
||||||
.BindT(MediaSourceMustHaveApiKey);
|
|
||||||
|
|
||||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
|
|
||||||
ISynchronizeEmbyLibraryById request) =>
|
|
||||||
_mediaSourceRepository.GetEmbyByLibraryId(request.EmbyLibraryId)
|
|
||||||
.Map(
|
|
||||||
v => v.ToValidation<BaseError>(
|
|
||||||
$"Emby media source for library {request.EmbyLibraryId} does not exist."));
|
|
||||||
|
|
||||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
|
||||||
EmbyMediaSource embyMediaSource)
|
|
||||||
{
|
|
||||||
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.HeadOrNone();
|
|
||||||
return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection))
|
|
||||||
.ToValidation<BaseError>("Emby media source requires an active connection");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
|
||||||
ConnectionParameters connectionParameters)
|
|
||||||
{
|
|
||||||
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
|
|
||||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
|
||||||
.Where(match => match)
|
|
||||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
|
||||||
.ToValidation<BaseError>("Emby media source requires an api key");
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task<Validation<BaseError, EmbyLibrary>> EmbyLibraryMustExist(
|
|
||||||
ISynchronizeEmbyLibraryById request) =>
|
|
||||||
_mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId)
|
|
||||||
.Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist."));
|
|
||||||
|
|
||||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
|
||||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
|
||||||
.FilterT(lri => lri > 0)
|
|
||||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
|
||||||
|
|
||||||
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
|
|
||||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
|
|
||||||
.FilterT(File.Exists)
|
|
||||||
.Map(
|
|
||||||
ffmpegPath =>
|
|
||||||
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
|
|
||||||
|
|
||||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
|
||||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
|
||||||
.FilterT(File.Exists)
|
|
||||||
.Map(
|
|
||||||
ffprobePath =>
|
|
||||||
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
|
||||||
|
|
||||||
private record RequestParameters(
|
|
||||||
ConnectionParameters ConnectionParameters,
|
|
||||||
EmbyLibrary Library,
|
|
||||||
bool ForceScan,
|
|
||||||
int LibraryRefreshInterval,
|
|
||||||
string FFmpegPath,
|
|
||||||
string FFprobePath);
|
|
||||||
|
|
||||||
private record ConnectionParameters(
|
|
||||||
EmbyMediaSource EmbyMediaSource,
|
|
||||||
EmbyConnection ActiveConnection)
|
|
||||||
{
|
|
||||||
public string ApiKey { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,15 @@ namespace ErsatzTV.Application.Emby;
|
|||||||
public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmbyMediaSources,
|
public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmbyMediaSources,
|
||||||
Either<BaseError, List<EmbyMediaSource>>>
|
Either<BaseError, List<EmbyMediaSource>>>
|
||||||
{
|
{
|
||||||
private readonly ChannelWriter<IEmbyBackgroundServiceRequest> _channel;
|
|
||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||||
|
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||||
|
|
||||||
public SynchronizeEmbyMediaSourcesHandler(
|
public SynchronizeEmbyMediaSourcesHandler(
|
||||||
IMediaSourceRepository mediaSourceRepository,
|
IMediaSourceRepository mediaSourceRepository,
|
||||||
ChannelWriter<IEmbyBackgroundServiceRequest> channel)
|
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel)
|
||||||
{
|
{
|
||||||
_mediaSourceRepository = mediaSourceRepository;
|
_mediaSourceRepository = mediaSourceRepository;
|
||||||
_channel = channel;
|
_scannerWorkerChannel = scannerWorkerChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Either<BaseError, List<EmbyMediaSource>>> Handle(
|
public async Task<Either<BaseError, List<EmbyMediaSource>>> Handle(
|
||||||
@@ -26,8 +26,7 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
|
|||||||
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby();
|
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby();
|
||||||
foreach (EmbyMediaSource mediaSource in mediaSources)
|
foreach (EmbyMediaSource mediaSource in mediaSources)
|
||||||
{
|
{
|
||||||
// await _channel.WriteAsync(new SynchronizeEmbyAdminUserId(mediaSource.Id), cancellationToken);
|
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||||
await _channel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return mediaSources;
|
return mediaSources;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.Emby;
|
namespace ErsatzTV.Application.Emby;
|
||||||
|
|
||||||
public record UpdateEmbyLibraryPreferences
|
public record UpdateEmbyLibraryPreferences(List<EmbyLibraryPreference> Preferences) : IRequest<Either<BaseError, Unit>>;
|
||||||
(List<EmbyLibraryPreference> Preferences) : IRequest<Either<BaseError, Unit>>;
|
|
||||||
|
|
||||||
public record EmbyLibraryPreference(int Id, bool ShouldSyncItems);
|
public record EmbyLibraryPreference(int Id, bool ShouldSyncItems);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Emby;
|
|||||||
|
|
||||||
public class
|
public class
|
||||||
UpdateEmbyLibraryPreferencesHandler : IRequestHandler<UpdateEmbyLibraryPreferences,
|
UpdateEmbyLibraryPreferencesHandler : IRequestHandler<UpdateEmbyLibraryPreferences,
|
||||||
Either<BaseError, Unit>>
|
Either<BaseError, Unit>>
|
||||||
{
|
{
|
||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||||
private readonly ISearchIndex _searchIndex;
|
private readonly ISearchIndex _searchIndex;
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
|||||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
|
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
|
||||||
UpdateEmbyPathReplacements request) =>
|
UpdateEmbyPathReplacements request) =>
|
||||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
|
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
|
||||||
.Map(
|
.Map(v => v.ToValidation<BaseError>(
|
||||||
v => v.ToValidation<BaseError>(
|
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
|
||||||
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
namespace ErsatzTV.Application.Emby;
|
namespace ErsatzTV.Application.Emby;
|
||||||
|
|
||||||
public record EmbyConnectionParametersViewModel(string Address);
|
public record EmbyConnectionParametersViewModel(string Address, string ApiKey);
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ using ErsatzTV.Core.Domain;
|
|||||||
namespace ErsatzTV.Application.Emby;
|
namespace ErsatzTV.Application.Emby;
|
||||||
|
|
||||||
public record EmbyLibraryViewModel(
|
public record EmbyLibraryViewModel(
|
||||||
int Id,
|
int Id,
|
||||||
string Name,
|
string Name,
|
||||||
LibraryMediaKind MediaKind,
|
LibraryMediaKind MediaKind,
|
||||||
bool ShouldSyncItems,
|
bool ShouldSyncItems,
|
||||||
int MediaSourceId)
|
int MediaSourceId)
|
||||||
: LibraryViewModel("Emby", Id, Name, MediaKind, MediaSourceId);
|
: LibraryViewModel("Emby", Id, Name, MediaKind, MediaSourceId, string.Empty);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Emby;
|
||||||
|
using ErsatzTV.Core.Interfaces.Emby;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
@@ -8,15 +10,18 @@ namespace ErsatzTV.Application.Emby;
|
|||||||
public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnectionParameters,
|
public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnectionParameters,
|
||||||
Either<BaseError, EmbyConnectionParametersViewModel>>
|
Either<BaseError, EmbyConnectionParametersViewModel>>
|
||||||
{
|
{
|
||||||
|
private readonly IEmbySecretStore _embySecretStore;
|
||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||||
private readonly IMemoryCache _memoryCache;
|
private readonly IMemoryCache _memoryCache;
|
||||||
|
|
||||||
public GetEmbyConnectionParametersHandler(
|
public GetEmbyConnectionParametersHandler(
|
||||||
IMemoryCache memoryCache,
|
IMemoryCache memoryCache,
|
||||||
IMediaSourceRepository mediaSourceRepository)
|
IMediaSourceRepository mediaSourceRepository,
|
||||||
|
IEmbySecretStore embySecretStore)
|
||||||
{
|
{
|
||||||
_memoryCache = memoryCache;
|
_memoryCache = memoryCache;
|
||||||
_mediaSourceRepository = mediaSourceRepository;
|
_mediaSourceRepository = mediaSourceRepository;
|
||||||
|
_embySecretStore = embySecretStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle(
|
public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle(
|
||||||
@@ -30,7 +35,7 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
|||||||
|
|
||||||
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
|
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
|
||||||
await Validate()
|
await Validate()
|
||||||
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address))
|
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address, cp.ApiKey))
|
||||||
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
|
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
|
||||||
|
|
||||||
return maybeParameters.Match(
|
return maybeParameters.Match(
|
||||||
@@ -44,13 +49,13 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
|||||||
|
|
||||||
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
||||||
EmbyMediaSourceMustExist()
|
EmbyMediaSourceMustExist()
|
||||||
.BindT(MediaSourceMustHaveActiveConnection);
|
.BindT(MediaSourceMustHaveActiveConnection)
|
||||||
|
.BindT(MediaSourceMustHaveApiKey);
|
||||||
|
|
||||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
|
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
|
||||||
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
|
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
|
||||||
.Map(
|
.Map(v => v.ToValidation<BaseError>(
|
||||||
v => v.ToValidation<BaseError>(
|
"Emby media source does not exist."));
|
||||||
"Emby media source does not exist."));
|
|
||||||
|
|
||||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||||
EmbyMediaSource embyMediaSource)
|
EmbyMediaSource embyMediaSource)
|
||||||
@@ -60,7 +65,20 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
|||||||
.ToValidation<BaseError>("Emby media source requires an active connection");
|
.ToValidation<BaseError>("Emby media source requires an active connection");
|
||||||
}
|
}
|
||||||
|
|
||||||
private record ConnectionParameters(
|
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||||
|
ConnectionParameters connectionParameters)
|
||||||
|
{
|
||||||
|
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
|
||||||
|
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||||
|
.Where(match => match)
|
||||||
|
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||||
|
.ToValidation<BaseError>("Emby media source requires an api key");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ConnectionParameters(
|
||||||
EmbyMediaSource EmbyMediaSource,
|
EmbyMediaSource EmbyMediaSource,
|
||||||
EmbyConnection ActiveConnection);
|
EmbyConnection ActiveConnection)
|
||||||
|
{
|
||||||
|
public string ApiKey { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
namespace ErsatzTV.Application.Emby;
|
namespace ErsatzTV.Application.Emby;
|
||||||
|
|
||||||
public record GetEmbyPathReplacementsBySourceId
|
public record GetEmbyPathReplacementsBySourceId(int EmbyMediaSourceId) : IRequest<List<EmbyPathReplacementViewModel>>;
|
||||||
(int EmbyMediaSourceId) : IRequest<List<EmbyPathReplacementViewModel>>;
|
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<NoWarn>VSTHRD200</NoWarn>
|
<NoWarn>VSTHRD200</NoWarn>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
<PackageReference Include="Bugsnag" Version="4.0.0" />
|
||||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
<PackageReference Include="CliWrap" Version="3.9.0" />
|
||||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.7" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.7" />
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
</PackageReference>
|
||||||
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||||
|
<PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
|
||||||
|
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
||||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=artists_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=artists_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=artworks_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=channels_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=channels_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=channels_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=channels_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=configuration_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=configuration_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||||
@@ -33,12 +34,18 @@
|
|||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=plex_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=plex_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=programschedules_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=programschedules_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=programschedules_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=programschedules_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=scheduling_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=scheduling_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=subtitles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=subtitles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=television_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=television_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=templates_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=troubleshooting_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=validators/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Cqueries/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Cqueries/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||||
|
|
||||||
public record CopyFFmpegProfile
|
public record CopyFFmpegProfile(int FFmpegProfileId, string Name) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||||
(int FFmpegProfileId, string Name) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||||
@@ -8,9 +10,13 @@ public class
|
|||||||
CopyFFmpegProfileHandler : IRequestHandler<CopyFFmpegProfile, Either<BaseError, FFmpegProfileViewModel>>
|
CopyFFmpegProfileHandler : IRequestHandler<CopyFFmpegProfile, Either<BaseError, FFmpegProfileViewModel>>
|
||||||
{
|
{
|
||||||
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
|
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
|
||||||
|
private readonly ISearchTargets _searchTargets;
|
||||||
|
|
||||||
public CopyFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository) =>
|
public CopyFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository, ISearchTargets searchTargets)
|
||||||
|
{
|
||||||
_ffmpegProfileRepository = ffmpegProfileRepository;
|
_ffmpegProfileRepository = ffmpegProfileRepository;
|
||||||
|
_searchTargets = searchTargets;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<Either<BaseError, FFmpegProfileViewModel>> Handle(
|
public Task<Either<BaseError, FFmpegProfileViewModel>> Handle(
|
||||||
CopyFFmpegProfile request,
|
CopyFFmpegProfile request,
|
||||||
@@ -19,14 +25,17 @@ public class
|
|||||||
.MapT(PerformCopy)
|
.MapT(PerformCopy)
|
||||||
.Bind(v => v.ToEitherAsync());
|
.Bind(v => v.ToEitherAsync());
|
||||||
|
|
||||||
private Task<FFmpegProfileViewModel> PerformCopy(CopyFFmpegProfile request) =>
|
private async Task<FFmpegProfileViewModel> PerformCopy(CopyFFmpegProfile request)
|
||||||
_ffmpegProfileRepository.Copy(request.FFmpegProfileId, request.Name)
|
{
|
||||||
.Map(ProjectToViewModel);
|
FFmpegProfile copy = await _ffmpegProfileRepository.Copy(request.FFmpegProfileId, request.Name);
|
||||||
|
_searchTargets.SearchTargetsChanged();
|
||||||
|
return ProjectToViewModel(copy);
|
||||||
|
}
|
||||||
|
|
||||||
private Task<Validation<BaseError, CopyFFmpegProfile>> Validate(CopyFFmpegProfile request) =>
|
private static Task<Validation<BaseError, CopyFFmpegProfile>> Validate(CopyFFmpegProfile request) =>
|
||||||
ValidateName(request).AsTask().MapT(_ => request);
|
ValidateName(request).AsTask().MapT(_ => request);
|
||||||
|
|
||||||
private Validation<BaseError, string> ValidateName(CopyFFmpegProfile request) =>
|
private static Validation<BaseError, string> ValidateName(CopyFFmpegProfile request) =>
|
||||||
request.NotEmpty(x => x.Name)
|
request.NotEmpty(x => x.Name)
|
||||||
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
|
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,24 @@ public record CreateFFmpegProfile(
|
|||||||
string Name,
|
string Name,
|
||||||
int ThreadCount,
|
int ThreadCount,
|
||||||
HardwareAccelerationKind HardwareAcceleration,
|
HardwareAccelerationKind HardwareAcceleration,
|
||||||
|
string VaapiDisplay,
|
||||||
VaapiDriver VaapiDriver,
|
VaapiDriver VaapiDriver,
|
||||||
string VaapiDevice,
|
string VaapiDevice,
|
||||||
|
int? QsvExtraHardwareFrames,
|
||||||
int ResolutionId,
|
int ResolutionId,
|
||||||
|
ScalingBehavior ScalingBehavior,
|
||||||
FFmpegProfileVideoFormat VideoFormat,
|
FFmpegProfileVideoFormat VideoFormat,
|
||||||
|
string VideoProfile,
|
||||||
|
string VideoPreset,
|
||||||
|
bool AllowBFrames,
|
||||||
|
FFmpegProfileBitDepth BitDepth,
|
||||||
int VideoBitrate,
|
int VideoBitrate,
|
||||||
int VideoBufferSize,
|
int VideoBufferSize,
|
||||||
|
FFmpegProfileTonemapAlgorithm TonemapAlgorithm,
|
||||||
FFmpegProfileAudioFormat AudioFormat,
|
FFmpegProfileAudioFormat AudioFormat,
|
||||||
int AudioBitrate,
|
int AudioBitrate,
|
||||||
int AudioBufferSize,
|
int AudioBufferSize,
|
||||||
bool NormalizeLoudness,
|
NormalizeLoudnessMode NormalizeLoudnessMode,
|
||||||
int AudioChannels,
|
int AudioChannels,
|
||||||
int AudioSampleRate,
|
int AudioSampleRate,
|
||||||
bool NormalizeFramerate,
|
bool NormalizeFramerate,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
using ErsatzTV.Infrastructure.Extensions;
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -10,9 +11,13 @@ public class CreateFFmpegProfileHandler :
|
|||||||
IRequestHandler<CreateFFmpegProfile, Either<BaseError, CreateFFmpegProfileResult>>
|
IRequestHandler<CreateFFmpegProfile, Either<BaseError, CreateFFmpegProfileResult>>
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly ISearchTargets _searchTargets;
|
||||||
|
|
||||||
public CreateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
public CreateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||||
|
{
|
||||||
_dbContextFactory = dbContextFactory;
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_searchTargets = searchTargets;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Either<BaseError, CreateFFmpegProfileResult>> Handle(
|
public async Task<Either<BaseError, CreateFFmpegProfileResult>> Handle(
|
||||||
CreateFFmpegProfile request,
|
CreateFFmpegProfile request,
|
||||||
@@ -20,43 +25,50 @@ public class CreateFFmpegProfileHandler :
|
|||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
||||||
return await LanguageExtensions.Apply(validation, profile => PersistFFmpegProfile(dbContext, profile));
|
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
|
private async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
FFmpegProfile ffmpegProfile)
|
FFmpegProfile ffmpegProfile)
|
||||||
{
|
{
|
||||||
await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile);
|
await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
_searchTargets.SearchTargetsChanged();
|
||||||
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
|
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
CreateFFmpegProfile request) =>
|
CreateFFmpegProfile request) =>
|
||||||
(ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(dbContext, request))
|
(ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(dbContext, request))
|
||||||
.Apply(
|
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
|
||||||
(name, threadCount, resolutionId) => new FFmpegProfile
|
{
|
||||||
{
|
Name = name,
|
||||||
Name = name,
|
ThreadCount = threadCount,
|
||||||
ThreadCount = threadCount,
|
HardwareAcceleration = request.HardwareAcceleration,
|
||||||
HardwareAcceleration = request.HardwareAcceleration,
|
VaapiDriver = request.VaapiDriver,
|
||||||
VaapiDriver = request.VaapiDriver,
|
VaapiDevice = request.VaapiDevice,
|
||||||
VaapiDevice = request.VaapiDevice,
|
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||||
ResolutionId = resolutionId,
|
ResolutionId = resolutionId,
|
||||||
VideoFormat = request.VideoFormat,
|
ScalingBehavior = request.ScalingBehavior,
|
||||||
VideoBitrate = request.VideoBitrate,
|
VideoFormat = request.VideoFormat,
|
||||||
VideoBufferSize = request.VideoBufferSize,
|
VideoProfile = request.VideoProfile,
|
||||||
AudioFormat = request.AudioFormat,
|
VideoPreset = request.VideoPreset,
|
||||||
AudioBitrate = request.AudioBitrate,
|
AllowBFrames = request.AllowBFrames,
|
||||||
AudioBufferSize = request.AudioBufferSize,
|
BitDepth = request.BitDepth,
|
||||||
NormalizeLoudness = request.NormalizeLoudness,
|
VideoBitrate = request.VideoBitrate,
|
||||||
AudioChannels = request.AudioChannels,
|
VideoBufferSize = request.VideoBufferSize,
|
||||||
AudioSampleRate = request.AudioSampleRate,
|
TonemapAlgorithm = request.TonemapAlgorithm,
|
||||||
NormalizeFramerate = request.NormalizeFramerate,
|
AudioFormat = request.AudioFormat,
|
||||||
DeinterlaceVideo = request.DeinterlaceVideo
|
AudioBitrate = request.AudioBitrate,
|
||||||
});
|
AudioBufferSize = request.AudioBufferSize,
|
||||||
|
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
|
||||||
|
AudioChannels = request.AudioChannels,
|
||||||
|
AudioSampleRate = request.AudioSampleRate,
|
||||||
|
NormalizeFramerate = request.NormalizeFramerate,
|
||||||
|
DeinterlaceVideo = request.DeinterlaceVideo
|
||||||
|
});
|
||||||
|
|
||||||
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
|
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
|
||||||
createFFmpegProfile.NotEmpty(x => x.Name)
|
createFFmpegProfile.NotEmpty(x => x.Name)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
using ErsatzTV.Infrastructure.Extensions;
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -9,23 +10,28 @@ namespace ErsatzTV.Application.FFmpegProfiles;
|
|||||||
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
|
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly ISearchTargets _searchTargets;
|
||||||
|
|
||||||
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||||
|
{
|
||||||
_dbContextFactory = dbContextFactory;
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_searchTargets = searchTargets;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Either<BaseError, Unit>> Handle(
|
public async Task<Either<BaseError, Unit>> Handle(
|
||||||
DeleteFFmpegProfile request,
|
DeleteFFmpegProfile request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
|
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
|
||||||
return await LanguageExtensions.Apply(validation, p => DoDeletion(dbContext, p));
|
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
|
private async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
|
||||||
{
|
{
|
||||||
dbContext.FFmpegProfiles.Remove(ffmpegProfile);
|
dbContext.FFmpegProfiles.Remove(ffmpegProfile);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
_searchTargets.SearchTargetsChanged();
|
||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user