Compare commits
641 Commits
hdr-vulkan
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e58bb9af21 | |||
| aa6d8eae4c | |||
| f1e97b94a7 | |||
| 5034941a79 | |||
|
|
0d301df5e8 | ||
|
|
d26ae336cb | ||
|
|
875069b927 | ||
|
|
fd86cb55f9 | ||
|
|
0c30c47ba9 | ||
|
|
08cbf59527 | ||
|
|
a91de68a5c | ||
|
|
3e3bfbd5f5 | ||
|
|
31b07305ef | ||
|
|
49adcf7c37 | ||
|
|
c0b8ff1a06 | ||
|
|
c6d538e012 | ||
|
|
3dbde17f68 | ||
|
|
794d209941 | ||
|
|
7b9197d48d | ||
|
|
2ad6547349 | ||
|
|
4fa11b6943 | ||
|
|
440d9f708e | ||
|
|
4d469ec8fd | ||
|
|
a77a2d56ae | ||
|
|
240a329526 | ||
|
|
45e7d61676 | ||
|
|
93811876e0 | ||
|
|
607d9b0662 | ||
|
|
f47134d2d0 | ||
|
|
ae13db981d | ||
|
|
b7cc8499a3 | ||
|
|
36147b9e9c | ||
|
|
bf8c821012 | ||
|
|
a0f5d8d5d5 | ||
|
|
f1072b70c7 | ||
|
|
e10b28bc0b | ||
|
|
cd2bb0f2e0 | ||
|
|
e80f687612 | ||
|
|
317ca1967c | ||
|
|
b86f45844c | ||
|
|
353f029452 | ||
|
|
1754e7d5fb | ||
|
|
f96be8f99f | ||
|
|
08ceb53b2b | ||
|
|
3d81f760ee | ||
|
|
4ce87feac1 | ||
|
|
f217ba185b | ||
|
|
e925bd6913 | ||
|
|
3f4c9e063b | ||
|
|
7f361d1ea9 | ||
|
|
35d24ffea6 | ||
|
|
a2d023ee69 | ||
|
|
36f44f14bb | ||
|
|
ccb917d0df | ||
|
|
343a4619a6 | ||
|
|
e167c9318c | ||
|
|
de230f92db | ||
|
|
974020a98f | ||
|
|
da957c9377 | ||
|
|
b72d150775 | ||
|
|
b0b7bd17b3 | ||
|
|
1f2f04f3bd | ||
|
|
5bc90bb245 | ||
|
|
f73a32ec13 | ||
|
|
748ed1cf71 | ||
|
|
f2deaa6f7a | ||
|
|
3698fa5b7d | ||
|
|
dc92cb4ac3 | ||
|
|
69410b1a9b | ||
|
|
4aee03e066 | ||
|
|
e16d6c67f1 | ||
|
|
5d8877975d | ||
|
|
367305d960 | ||
|
|
aa08ad5765 | ||
|
|
40c6c504fe | ||
|
|
933b6530e4 | ||
|
|
885330f8c5 | ||
|
|
effb96a2c2 | ||
|
|
cc521326d9 | ||
|
|
5c42609527 | ||
|
|
a96ef328a5 | ||
|
|
89bb3759de | ||
|
|
12f2583c96 | ||
|
|
7c82ecdfff | ||
|
|
38343e3ea2 | ||
|
|
bcd2ea7db3 | ||
|
|
80f6e468eb | ||
|
|
474e647d6d | ||
|
|
daff1c6533 | ||
|
|
14d2dd0c3a | ||
|
|
c606319030 | ||
|
|
1b72b8491c | ||
|
|
9fea25a77d | ||
|
|
74b049b6e3 | ||
|
|
b2caf8ee8d | ||
|
|
b582b4cbf7 | ||
|
|
0af81ad839 | ||
|
|
2f0cd1eb6c | ||
|
|
e4f1a93db0 | ||
|
|
6562d616fb | ||
|
|
d8122edad6 | ||
|
|
99b8c56a31 | ||
|
|
09858df654 | ||
|
|
038286c92b | ||
|
|
8575ab5c32 | ||
|
|
8b768a2990 | ||
|
|
f9e4c4d386 | ||
|
|
a1f9b86fc1 | ||
|
|
5dc20ebd1b | ||
|
|
d30e8b4102 | ||
|
|
c14f373f23 | ||
|
|
a90fe26d50 | ||
|
|
7a263ddaed | ||
|
|
3e0a9aae1e | ||
|
|
72dc401829 | ||
|
|
85e25ca6ea | ||
|
|
9c23b03758 | ||
|
|
e12888ebee | ||
|
|
468ff087d4 | ||
|
|
54606c76f9 | ||
|
|
6bd49ffcec | ||
|
|
c524bc0d7d | ||
|
|
42bcadf936 | ||
|
|
1f31beab5b | ||
|
|
b45c22092d | ||
|
|
7bd8cefe2e | ||
|
|
f101d0b366 | ||
|
|
73aabdabda | ||
|
|
bcea96d53a | ||
|
|
d7952e4cfa | ||
|
|
758399e339 | ||
|
|
6c635a4be9 | ||
|
|
9d637cdd54 | ||
|
|
cc287ffc6e | ||
|
|
371659c5c5 | ||
|
|
7afb1866ad | ||
|
|
7bc1dd63fe | ||
|
|
076b8a7188 | ||
|
|
ec0d8ea6ac | ||
|
|
e40d192aea | ||
|
|
bd7fd8984c | ||
|
|
2682912f5a | ||
|
|
505e135482 | ||
|
|
fdf1e70e0d | ||
|
|
5c51710e2f | ||
|
|
3cb84c2491 | ||
|
|
21f4439aa4 | ||
|
|
d88e721d2f | ||
|
|
b6d509b9cd | ||
|
|
6603500132 | ||
|
|
48b1aa3e64 | ||
|
|
42b35f7aae | ||
|
|
8b18f2a304 | ||
|
|
1e0bba0dc6 | ||
|
|
3984bc7dbe | ||
|
|
e0977fa65b | ||
|
|
d9c668c7f6 | ||
|
|
dcea6d474f | ||
|
|
ba37c6dabe | ||
|
|
d652372f78 | ||
|
|
e2d8dee8cd | ||
|
|
d93c404607 | ||
|
|
bc400de94c | ||
|
|
b9a73226a8 | ||
|
|
d0505cd5c5 | ||
|
|
d9cdbc72de | ||
|
|
2b0079fedc | ||
|
|
9d2cff53c5 | ||
|
|
ac361b3165 | ||
|
|
dd9317e3e8 | ||
|
|
7530c592ff | ||
|
|
132466b3d3 | ||
|
|
d709cc9f21 | ||
|
|
5083e748ed | ||
|
|
053b3cd1d7 | ||
|
|
c3c7ff2669 | ||
|
|
e6824cf251 | ||
|
|
d87561d140 | ||
|
|
f79fa9a50a | ||
|
|
629b3d7d9f | ||
|
|
453737a521 | ||
|
|
dd38ba19ea | ||
|
|
8e2a15296f | ||
|
|
d2cbfcb79a | ||
|
|
89133255d3 | ||
|
|
c6245bae0c | ||
|
|
2912e71c10 | ||
|
|
9e54d42e5f | ||
|
|
63f342e6a7 | ||
|
|
5a7c59d602 | ||
|
|
4822ba5486 | ||
|
|
b24617fe7c | ||
|
|
5045a411b1 | ||
|
|
425fb34317 | ||
|
|
e5ef9be09c | ||
|
|
e9338b534b | ||
|
|
191e694545 | ||
|
|
727a978689 | ||
|
|
7a133d46da | ||
|
|
b1fbf651a2 | ||
|
|
0dbdcc3674 | ||
|
|
9eb7bbf0e6 | ||
|
|
e851a295a6 | ||
|
|
3b254735e6 | ||
|
|
1f8834c280 | ||
|
|
545db4db9b | ||
|
|
e590298b93 | ||
|
|
a47510fef3 | ||
|
|
e089b12c2b | ||
|
|
82e0fcaec8 | ||
|
|
f7c699248c | ||
|
|
2d53063ce9 | ||
|
|
626048f9c3 | ||
|
|
2ef2b0299a | ||
|
|
fcce53a3df | ||
|
|
d4353f6d42 | ||
|
|
64ea413b6f | ||
|
|
d14ebf3522 | ||
|
|
889904e70d | ||
|
|
35e7922836 | ||
|
|
ffe15629cb | ||
|
|
ba5a027525 | ||
|
|
a33ac4a048 | ||
|
|
7ae028e2e9 | ||
|
|
6404dee646 | ||
|
|
940d26419c | ||
|
|
9bae8e73bf | ||
|
|
f41f4b19d4 | ||
|
|
917acf9683 | ||
|
|
da4687ac0f | ||
|
|
d1af6599f0 | ||
|
|
4e43817f8e | ||
|
|
ebcd9a35a7 | ||
|
|
ea5956a268 | ||
|
|
65ff1f5502 | ||
|
|
5ef8b04119 | ||
|
|
99837e808a | ||
|
|
7c2083d3f2 | ||
|
|
b851a7daba | ||
|
|
48e7c85f7b | ||
|
|
bf4182f115 | ||
|
|
fb9ca8953e | ||
|
|
48310e044b | ||
|
|
144b3fe80b | ||
|
|
4a754c4e6a | ||
|
|
7059669023 | ||
|
|
c03f81a465 | ||
|
|
e3d07050bf | ||
|
|
5a88bfc310 | ||
|
|
dd92a65742 | ||
|
|
07ffa1642b | ||
|
|
6fc602323f | ||
|
|
d5fd8e7be6 | ||
|
|
dba5485300 | ||
|
|
6847a133ca | ||
|
|
fd60c120ae | ||
|
|
371d1d89fb | ||
|
|
ec6bc797f4 | ||
|
|
4c57167864 | ||
|
|
9016523757 | ||
|
|
6a38c91d54 | ||
|
|
9ec4d0a85c | ||
|
|
4cb98242ba | ||
|
|
0e2084838a | ||
|
|
f3f900b4ca | ||
|
|
c39858b2d8 | ||
|
|
0363609923 | ||
|
|
a02aa37957 | ||
|
|
86a7563da5 | ||
|
|
cd4715a32e | ||
|
|
fc04118bf9 | ||
|
|
dd5fd1ef8f | ||
|
|
404f898b2f | ||
|
|
3e8ac9914c | ||
|
|
a0788532a0 | ||
|
|
b3daad7c3b | ||
|
|
598de5d5d6 | ||
|
|
5c174eabdb | ||
|
|
18905a79dc | ||
|
|
077fed6cac | ||
|
|
fac0f36d35 | ||
|
|
287adc34b5 | ||
|
|
6ff153f01d | ||
|
|
e3af0f0b69 | ||
|
|
b46de50801 | ||
|
|
77163e6746 | ||
|
|
1763c897eb | ||
|
|
ac45d6acd4 | ||
|
|
8b4b7cf16a | ||
|
|
b820b798cb | ||
|
|
dc92a96bd9 | ||
|
|
18523dce64 | ||
|
|
ffb50a9404 | ||
|
|
7462039301 | ||
|
|
c71269058e | ||
|
|
9ec220c122 | ||
|
|
fc97a2da3c | ||
|
|
ddc1120904 | ||
|
|
e70e4fb826 | ||
|
|
b790b5944c | ||
|
|
0ca1859802 | ||
|
|
07a160fcc6 | ||
|
|
788a1ecdc4 | ||
|
|
004da8b7aa | ||
|
|
c8679144c5 | ||
|
|
a389c1bbbe | ||
|
|
ecacf3960f | ||
|
|
aa5ba5a78e | ||
|
|
9e111a103e | ||
|
|
8bc3457de0 | ||
|
|
febabcff6f | ||
|
|
f26e48c063 | ||
|
|
6465c416ff | ||
|
|
a0e0bb8753 | ||
|
|
b9451a6585 | ||
|
|
0c49f4799f | ||
|
|
ea008776b1 | ||
|
|
9aa7c44388 | ||
|
|
d855e4f20d | ||
|
|
9da655e210 | ||
|
|
03b9db7835 | ||
|
|
245165c9d9 | ||
|
|
fe5dd80f70 | ||
|
|
8155e2e441 | ||
|
|
307b9dadd2 | ||
|
|
5379a893f7 | ||
|
|
64bb1b0d61 | ||
|
|
89968f722a | ||
|
|
bc721755f5 | ||
|
|
9182a8ad18 | ||
|
|
c5265943f5 | ||
|
|
07a55da76e | ||
|
|
3722bc8c9c | ||
|
|
87bc779d48 | ||
|
|
f124554fba | ||
|
|
17c7774603 | ||
|
|
4e065fe922 | ||
|
|
2cca3123aa | ||
|
|
dabc67976a | ||
|
|
bd6954121f | ||
|
|
a2fd23a131 | ||
|
|
388623f82e | ||
|
|
6b275f8a13 | ||
|
|
0d69dd58a4 | ||
|
|
79e8fa0877 | ||
|
|
044c8b7ad3 | ||
|
|
e8b51e8442 | ||
|
|
d8e8abb691 | ||
|
|
e9093d0c48 | ||
|
|
cb78b21d1c | ||
|
|
906ec44a6e | ||
|
|
e96ac0202b | ||
|
|
5e7da19e5e | ||
|
|
e25b669cc4 | ||
|
|
d80c6737a9 | ||
|
|
ef5de99f9c | ||
|
|
e047812a68 | ||
|
|
3da5144a0d | ||
|
|
31355ab887 | ||
|
|
63bac272cd | ||
|
|
8d5a208129 | ||
|
|
487d99dc69 | ||
|
|
40ab7c2cff | ||
|
|
a6de96c2ea | ||
|
|
0d82e0234b | ||
|
|
1e9e41b808 | ||
|
|
57e9b4d264 | ||
|
|
73b8d68a09 | ||
|
|
03921e1ff7 | ||
|
|
70aae67873 | ||
|
|
67cb931a47 | ||
|
|
704c1ec535 | ||
|
|
06332c8360 | ||
|
|
03b4419f67 | ||
|
|
7ac93c6aad | ||
|
|
6ca72baa00 | ||
|
|
6b953ab5ca | ||
|
|
272f528f7a | ||
|
|
07c1156a63 | ||
|
|
eadacc7f8c | ||
|
|
380070731a | ||
|
|
7720e6ba39 | ||
|
|
8a1cf72209 | ||
|
|
b9759c983c | ||
|
|
9462156148 | ||
|
|
1c07df5bc3 | ||
|
|
a6198892f0 | ||
|
|
02a91c4e14 | ||
|
|
b62a76d339 | ||
|
|
d9f2f51aee | ||
|
|
8e77330781 | ||
|
|
66c28e9b5f | ||
|
|
51ec84c94a | ||
|
|
a072e4357e | ||
|
|
605c57bef3 | ||
|
|
4e2310d008 | ||
|
|
61a99c250a | ||
|
|
bbddd50f00 | ||
|
|
53f281ce32 | ||
|
|
e06ee54070 | ||
|
|
af23c6d541 | ||
|
|
988ed8db04 | ||
|
|
31c18162e1 | ||
|
|
0318e71745 | ||
|
|
1e7f9a5709 | ||
|
|
330195d5e3 | ||
|
|
5d081ceeff | ||
|
|
6d32dac51b | ||
|
|
4f02bedf69 | ||
|
|
d71443ef60 | ||
|
|
d5608ac75f | ||
|
|
a6b01cbe28 | ||
|
|
d0af507bef | ||
|
|
f626954eb7 | ||
|
|
62e140ec98 | ||
|
|
93bb7a0531 | ||
|
|
f31a48c429 | ||
|
|
0841bc400b | ||
|
|
8cc0d30c0e | ||
|
|
4b18ee6b66 | ||
|
|
558e2ce333 | ||
|
|
c9e6e601c2 | ||
|
|
d28d0a9805 | ||
|
|
ac75a67709 | ||
|
|
5e463758da | ||
|
|
2cb0d12701 | ||
|
|
44ec0f8a0f | ||
|
|
b149f7f2a3 | ||
|
|
771bfba01c | ||
|
|
820c2a5ccc | ||
|
|
91c4e8f575 | ||
|
|
a04adf45c0 | ||
|
|
8cbc3b083a | ||
|
|
1cac210765 | ||
|
|
6f9952924b | ||
|
|
1bf5b9567b | ||
|
|
a9f2037648 | ||
|
|
03c5b7e664 | ||
|
|
0e7ec6e3b9 | ||
|
|
3f247288d3 | ||
|
|
df0801f2c6 | ||
|
|
908125f8a9 | ||
|
|
942cf9e225 | ||
|
|
075f3fcac7 | ||
|
|
f4eadae8ff | ||
|
|
2dc5bf58a7 | ||
|
|
76a589b538 | ||
|
|
9f3db05c17 | ||
|
|
7ca2763109 | ||
|
|
14539d00d4 | ||
|
|
bd09f3dfdc | ||
|
|
0c22eefad2 | ||
|
|
2f06e5b6f7 | ||
|
|
f9db92d5e6 | ||
|
|
f2b6f5b919 | ||
|
|
c7fcaf8886 | ||
|
|
5a5c049835 | ||
|
|
a28f40e14b | ||
|
|
a2fc99229e | ||
|
|
036b6e63c7 | ||
|
|
fd7c3fc25a | ||
|
|
93dca6e0e0 | ||
|
|
e34368bf07 | ||
|
|
a4b485f562 | ||
|
|
6159b6a5b2 | ||
|
|
11100a788b | ||
|
|
b40ac9ef52 | ||
|
|
c055e59723 | ||
|
|
b52159e8db | ||
|
|
a728c5e31e | ||
|
|
61ce1bad08 | ||
|
|
ab2b926de0 | ||
|
|
3b955255ce | ||
|
|
16dd2c2d81 | ||
|
|
48f93b8af8 | ||
|
|
8b12ee459a | ||
|
|
b3d0b44e77 | ||
|
|
163fd0c1f3 | ||
|
|
b6ec16c6a7 | ||
|
|
aa3bd3b750 | ||
|
|
f04b7ead09 | ||
|
|
8921273900 | ||
|
|
0489741123 | ||
|
|
c3e882085b | ||
|
|
3ab9112c15 | ||
|
|
33b789db67 | ||
|
|
ed5206b855 | ||
|
|
baf7aa20d1 | ||
|
|
7bd0de99e1 | ||
|
|
96093c8cc8 | ||
|
|
8430a3048c | ||
|
|
06d9e59a7a | ||
|
|
9c434079d5 | ||
|
|
12c88a006d | ||
|
|
f0ca358c2b | ||
|
|
093abf7ad8 | ||
|
|
f768093df7 | ||
|
|
3830db60bf | ||
|
|
5984b38ce0 | ||
|
|
e0175fc4e5 | ||
|
|
4f104cff5b | ||
|
|
a2f678fe8e | ||
|
|
b3ac0c68a8 | ||
|
|
605d8a98ab | ||
|
|
00f40c2568 | ||
|
|
74733a8026 | ||
|
|
1df9104854 | ||
|
|
6c6ccfa94b | ||
|
|
e9d494c24e | ||
|
|
deff33c76c | ||
|
|
b5d1839d55 | ||
|
|
ab0f431c85 | ||
|
|
9511e6e6a7 | ||
|
|
7f2b5ba47f | ||
|
|
478d19405d | ||
|
|
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 |
167
.claude/skills/ersatztv/SKILL.md
Normal file
167
.claude/skills/ersatztv/SKILL.md
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: ersatztv
|
||||
description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when managing custom TV channels.
|
||||
---
|
||||
|
||||
# ErsatzTV Channel Management
|
||||
|
||||
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
|
||||
Web UI: internal only (`http://localhost:8409` via SSH)
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
|
||||
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
|
||||
|
||||
## Architecture
|
||||
|
||||
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
|
||||
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
|
||||
- **POST endpoints**: library scan, playout reset, show scan
|
||||
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
|
||||
|
||||
## REST API
|
||||
|
||||
```bash
|
||||
# Via docker exec
|
||||
docker exec ersatztv curl -s http://localhost:8409/api/ENDPOINT
|
||||
```
|
||||
|
||||
### Read Endpoints (GET)
|
||||
```
|
||||
/api/channels # List channels
|
||||
/api/collections # List collections
|
||||
/api/schedules # List schedules
|
||||
/api/playouts # List playouts
|
||||
/api/shows # List shows
|
||||
/api/movies # List movies
|
||||
/api/artists # List artists
|
||||
/api/search # Search items
|
||||
/api/ffmpeg/profiles # FFmpeg profiles
|
||||
/api/watermarks # Watermarks
|
||||
/iptv/channels.m3u # M3U playlist (for Jellyfin)
|
||||
/iptv/xmltv.xml # XMLTV guide data
|
||||
```
|
||||
|
||||
### Mutation Endpoints (POST)
|
||||
```bash
|
||||
# Library scan
|
||||
POST /api/libraries/{id}/scan
|
||||
|
||||
# Scan single show
|
||||
POST /api/libraries/{id}/scan-show \
|
||||
-H "Content-Type: application/json" -d '{"ShowTitle":"Name","DeepScan":false}'
|
||||
|
||||
# Reset channel playout (rebuilds schedule)
|
||||
POST /api/channels/{channelNumber}/playout/reset
|
||||
```
|
||||
|
||||
## SQLite DB Operations
|
||||
|
||||
```bash
|
||||
# Read queries (safe while running, WAL mode)
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
|
||||
# Write queries — stop container first
|
||||
docker stop ersatztv
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
docker start ersatztv
|
||||
```
|
||||
|
||||
### Key Queries
|
||||
```sql
|
||||
-- List channels
|
||||
SELECT Id, Number, Name FROM Channel ORDER BY CAST(Number AS INTEGER);
|
||||
|
||||
-- List collections with item counts
|
||||
SELECT c.Id, c.Name, COUNT(ci.Id) as items FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
|
||||
|
||||
-- List schedules
|
||||
SELECT Id, Name FROM ProgramSchedule;
|
||||
|
||||
-- Playout (channel-schedule links)
|
||||
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule FROM Playout p JOIN Channel c ON p.ChannelId = c.Id LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id;
|
||||
|
||||
-- Media counts
|
||||
SELECT 'Shows' as type, COUNT(*) FROM Show UNION ALL SELECT 'Movies', COUNT(*) FROM Movie UNION ALL SELECT 'Episodes', COUNT(*) FROM Episode UNION ALL SELECT 'MusicVideos', COUNT(*) FROM MusicVideo;
|
||||
|
||||
-- Jellyfin source
|
||||
SELECT jms.Id, jc.Address, jms.ServerName FROM JellyfinMediaSource jms JOIN JellyfinConnection jc ON jc.JellyfinMediaSourceId = jms.Id;
|
||||
|
||||
-- Library sync status
|
||||
SELECT l.Id, l.Name, l.MediaKind, jl.ShouldSyncItems FROM Library l JOIN JellyfinLibrary jl ON jl.Id = l.Id;
|
||||
```
|
||||
|
||||
### Channel Setup Workflow (DB)
|
||||
|
||||
**Show-specific channel** (single TV show, shuffled):
|
||||
```sql
|
||||
-- 1. Schedule
|
||||
INSERT INTO ProgramSchedule (Id, FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, Name, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
|
||||
VALUES (<id>, 0, 0, '<name>', 1, 0, 1);
|
||||
-- 2. Schedule item (CollectionType=1 for Show, PlaybackOrder=3 for Shuffle)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, MediaItemId, PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, 1, 0, 0, 0, 0, 0, 0, <show_id>, 3, <schedule_id>);
|
||||
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
|
||||
-- 3. Channel
|
||||
INSERT INTO Channel (Id, Categories, FFmpegProfileId, FallbackFillerId, "Group", IdleBehavior, IsEnabled, MirrorSourceChannelId, MusicVideoCreditsMode, MusicVideoCreditsTemplate, Name, Number, PlayoutMode, PlayoutOffset, PlayoutSource, PreferredAudioLanguageCode, PreferredAudioTitle, PreferredSubtitleLanguageCode, ShowInEpg, SongVideoMode, SortNumber, StreamSelector, StreamSelectorMode, StreamingMode, SubtitleMode, TranscodeMode, UniqueId, WatermarkId)
|
||||
VALUES (<id>, '', 1, NULL, '<category>', 0, 1, NULL, 0, NULL, '<name>', '<number>', 0, NULL, 0, NULL, NULL, 'eng', 1, 0, <number>.0, NULL, 0, 4, 2, 0, lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))), 1);
|
||||
-- 4. Playout
|
||||
INSERT INTO Playout (Id, ChannelId, ProgramScheduleId, ScheduleKind, Seed)
|
||||
VALUES (<id>, <channel_id>, <schedule_id>, 0, abs(random()) % 1000000);
|
||||
```
|
||||
|
||||
**Collection-based channel** (multiple shows, shuffled):
|
||||
```sql
|
||||
-- 1. Collection + items (MediaItemId = Show.Id)
|
||||
INSERT INTO Collection (Id, Name, UseCustomPlaybackOrder) VALUES (<id>, '<name>', 0);
|
||||
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <show_id>);
|
||||
-- 2. Schedule (same as above but CollectionType=0, CollectionId set instead of MediaItemId)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, ..., PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, <coll_id>, 0, ..., 3, <schedule_id>);
|
||||
-- 3-4. Channel + Playout same as show-specific
|
||||
```
|
||||
|
||||
After creating: `POST /api/channels/{number}/playout/reset`
|
||||
|
||||
## Volume Mounts (matches Jellyfin)
|
||||
|
||||
| Host Path | Container Path |
|
||||
|-----------|---------------|
|
||||
| `~/downloadswarm/ersatztv` | `/config` |
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` (ro) |
|
||||
| `/mnt/episodes` | `/data/episodes` (ro) |
|
||||
| `/mnt/media/movies` | `/data/movies` (ro) |
|
||||
| `/mnt/media/standup` | `/data/standup` (ro) |
|
||||
| `/mnt/media/music_videos` | `/data/music` (ro) |
|
||||
|
||||
## FFmpeg & Hardware
|
||||
|
||||
- QSV (Intel Quick Sync) hardware acceleration
|
||||
- Resolution: 1920x1080, H264, AAC stereo
|
||||
- Device: `/dev/dri` passed through
|
||||
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf
|
||||
|
||||
## Jellyfin Integration
|
||||
|
||||
- Secrets: `/config/jellyfin-secrets.json` (`{"Address":"http://jellyfin:8096","ApiKey":"978033be716d46678a5d3c54ae0e0ff9"}`)
|
||||
- Libraries: Movies(10), TV Shows(11), Music Videos(8), Standup(9)
|
||||
- `JellyfinLibrary.ShouldSyncItems` must be `1` for scans to work
|
||||
|
||||
## Gotchas
|
||||
|
||||
- DB owned by root — always use `sudo sqlite3`
|
||||
- WAL mode: reads OK while running, stop container for writes
|
||||
- No REST API for channel/collection/schedule CRUD — DB scripting only
|
||||
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
|
||||
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
|
||||
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
|
||||
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
|
||||
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
|
||||
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
|
||||
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
|
||||
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
|
||||
- MediaItem.State: 0=Normal, 1=FileNotFound — clean up state=1 items by deleting cascading deps
|
||||
- ProgramSchedule required NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows
|
||||
- Channel required NOT NULL columns: SongVideoMode (set 0), plus all standard columns (see Channel table schema)
|
||||
- After schedule changes, rebuild playout: `POST /api/channels/{number}/playout/reset`
|
||||
- Playout `ScheduleKind` must be `1` (not `0`/None) — `0` causes "Cannot build playout type None" error
|
||||
- M3U `tvg-logo` URLs hardcode `http://localhost:8409` — Jellyfin can't fetch these from inside its container. Fix by downloading logos from ETV and base64-uploading to Jellyfin (see `docs/Docker/ErsatzTV.md` for script). Tracked in issue #171
|
||||
- Repo archived Feb 2026, v26.3.0 is final stable version. Maintainer welcomes forks
|
||||
105
.claude/skills/jellyfin/SKILL.md
Normal file
105
.claude/skills/jellyfin/SKILL.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: jellyfin
|
||||
description: Jellyfin media server management — API for libraries, items, streaming, users. Use when managing media library or checking Jellyfin status.
|
||||
---
|
||||
|
||||
# Jellyfin Management
|
||||
|
||||
Container: `jellyfin` | Port: `8096` | IP: `172.16.238.20` (may change on restart)
|
||||
API Token: `978033be716d46678a5d3c54ae0e0ff9`
|
||||
Web UI: `https://jellyfin.tblindustries.be` (NO Authelia — native login, password: `coup1802`)
|
||||
Config: `/home/timothy/downloadswarm/jellyfin/` on jazz
|
||||
|
||||
## Access Pattern
|
||||
|
||||
```bash
|
||||
docker exec jellyfin curl -s 'http://localhost:8096/ENDPOINT' \
|
||||
-H 'X-Emby-Token: 978033be716d46678a5d3c54ae0e0ff9'
|
||||
```
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
| Host Path | Container Path | Content |
|
||||
|-----------|---------------|---------|
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` | TV shows |
|
||||
| `/mnt/episodes` | `/data/episodes` | More episodes |
|
||||
| `/mnt/media/movies` | `/data/movies` | Movies |
|
||||
| `/mnt/media/standup` | `/data/standup` | Standup |
|
||||
| `/mnt/media/music_videos` | `/data/music` | Music videos |
|
||||
| `/mnt/media/audio/music` | `/data/audio` | Music audio (ro) |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### System
|
||||
```
|
||||
GET /System/Info # Server info, version
|
||||
GET /System/Info/Public # Public info (no auth needed)
|
||||
POST /System/Restart # Restart server
|
||||
```
|
||||
|
||||
### Items (Search & Browse)
|
||||
```bash
|
||||
# Search items
|
||||
GET /Items?includeItemTypes=Movie,Episode,Series&recursive=true&searchTerm=QUERY&fields=Path&limit=20
|
||||
|
||||
# Get item details
|
||||
GET /Items?ids=ITEM_ID&fields=Path,MediaStreams,Overview
|
||||
|
||||
# Get all movies
|
||||
GET /Items?includeItemTypes=Movie&recursive=true&fields=Path&limit=1000
|
||||
|
||||
# Get series
|
||||
GET /Items?includeItemTypes=Series&recursive=true&fields=Path
|
||||
|
||||
# Get episodes for a series
|
||||
GET /Shows/{seriesId}/Episodes?fields=Path,MediaStreams
|
||||
|
||||
# Filter by library (parentId)
|
||||
GET /Items?parentId=LIBRARY_ID&recursive=true&fields=Path
|
||||
```
|
||||
|
||||
### Libraries
|
||||
```
|
||||
GET /Library/VirtualFolders # List all libraries
|
||||
POST /Library/Refresh # Trigger full library scan
|
||||
POST /Items/{id}/Refresh # Refresh single item metadata
|
||||
```
|
||||
|
||||
### Streaming
|
||||
```bash
|
||||
# Test stream URL
|
||||
GET /Videos/{itemId}/stream?static=true
|
||||
|
||||
# Get playback info
|
||||
GET /Items/{itemId}/PlaybackInfo
|
||||
```
|
||||
|
||||
### Users
|
||||
```
|
||||
GET /Users # List users
|
||||
GET /Users/{userId} # User details
|
||||
```
|
||||
|
||||
## Library IDs
|
||||
|
||||
Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/VirtualFolders`
|
||||
|
||||
## Live TV
|
||||
|
||||
- **ErsatzTV** (channels <1000): M3U `http://ersatztv:8409/iptv/channels.m3u`, XMLTV `http://ersatztv:8409/iptv/xmltv.xml`
|
||||
- **Dispatcharr** (channels 1000+): IPTV stream manager on port 9191, separate tuner
|
||||
- Configured in Jellyfin Admin > Live TV
|
||||
- Guide refresh task ID: `bea9b218c97bbf98c5dc1303bdb9a0ca` — trigger via `POST /ScheduledTasks/Running/{id}`
|
||||
- **Logo fix after guide refresh**: ErsatzTV logos break (aspect ratio=0) because M3U uses `localhost:8409`. Fix script in `docs/Docker/ErsatzTV.md` downloads from ETV and base64-uploads to `POST /Items/{id}/Images/Primary` (body = base64, Content-Type = image/png)
|
||||
- **Image upload format**: Jellyfin expects base64-encoded body (NOT raw binary) for `POST /Items/{id}/Images/Primary`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
|
||||
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
|
||||
- Music videos are typed as "Movie" in Jellyfin
|
||||
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
|
||||
- Items return 404 on stream if source volume is unmounted
|
||||
- Jellyfin preserves item IDs across restarts unless files are renamed
|
||||
- Full library scan can take a long time — prefer targeted `/Items/{id}/Refresh`
|
||||
- `ffprobe` available in container for checking media streams: `docker exec jellyfin ffprobe -v quiet -print_format json -show_streams FILE`
|
||||
@@ -3,10 +3,11 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2024.1.1",
|
||||
"version": "2025.3.0.2",
|
||||
"commands": [
|
||||
"jb"
|
||||
]
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
[*]
|
||||
charset=utf-8
|
||||
end_of_line=lf
|
||||
trim_trailing_whitespace=true
|
||||
insert_final_newline=false
|
||||
insert_final_newline=true
|
||||
indent_style=space
|
||||
indent_size=4
|
||||
|
||||
@@ -15,7 +14,7 @@ csharp_style_expression_bodied_constructors=true:none
|
||||
csharp_style_expression_bodied_methods=true:none
|
||||
csharp_style_expression_bodied_properties=true:suggestion
|
||||
csharp_style_var_elsewhere=false:suggestion
|
||||
csharp_style_var_for_built_in_types=false:suggestion
|
||||
csharp_style_var_for_built_in_types=false:none
|
||||
csharp_style_var_when_type_is_apparent=true:suggestion
|
||||
dotnet_naming_rule.local_constants_rule.severity=warning
|
||||
dotnet_naming_rule.local_constants_rule.style=all_upper_style
|
||||
@@ -42,6 +41,8 @@ resharper_braces_for_for=required
|
||||
resharper_braces_for_foreach=required
|
||||
resharper_braces_for_ifelse=required
|
||||
resharper_braces_for_while=required
|
||||
resharper_csharp_arguments_literal=positional
|
||||
resharper_csharp_arguments_named=positional
|
||||
resharper_csharp_insert_final_newline=true
|
||||
resharper_csharp_max_attribute_length_for_same_line=0
|
||||
resharper_csharp_place_accessorholder_attribute_on_same_line=never
|
||||
@@ -66,7 +67,7 @@ resharper_built_in_type_reference_style_highlighting=hint
|
||||
resharper_redundant_base_qualifier_highlighting=warning
|
||||
resharper_suggest_var_or_type_built_in_types_highlighting=hint
|
||||
resharper_suggest_var_or_type_elsewhere_highlighting=hint
|
||||
resharper_suggest_var_or_type_simple_types_highlighting=hint
|
||||
resharper_suggest_var_or_type_simple_types_highlighting=none
|
||||
resharper_web_config_module_not_resolved_highlighting=warning
|
||||
resharper_web_config_type_not_resolved_highlighting=warning
|
||||
resharper_web_config_wrong_module_highlighting=warning
|
||||
@@ -84,7 +85,22 @@ tab_width=4
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
ij_json_array_wrapping = normal
|
||||
ij_json_keep_blank_lines_in_code = 0
|
||||
ij_json_keep_indents_on_empty_lines = false
|
||||
ij_json_keep_line_breaks = true
|
||||
ij_json_keep_trailing_comma = false
|
||||
ij_json_object_wrapping = normal
|
||||
ij_json_property_alignment = do_not_align
|
||||
ij_json_space_after_colon = true
|
||||
ij_json_space_after_comma = true
|
||||
ij_json_space_before_colon = false
|
||||
ij_json_space_before_comma = false
|
||||
ij_json_spaces_within_braces = true
|
||||
ij_json_spaces_within_brackets = true
|
||||
ij_json_wrap_long_lines = false
|
||||
|
||||
[*.cs]
|
||||
# disable CA1848: Use the LoggerMessage delegates`
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Requests
|
||||
url: https://features.ersatztv.org
|
||||
about: Features
|
||||
- name: Contact
|
||||
url: https://ersatztv.org/contact
|
||||
about: Chat Options
|
||||
- name: Community
|
||||
url: https://discuss.ersatztv.org
|
||||
about: Forum
|
||||
- name: Discussions
|
||||
url: https://github.com/ErsatzTV/ErsatzTV/discussions
|
||||
about: Discuss
|
||||
77
.github/ISSUE_TEMPLATE/issue.yml
vendored
Normal file
77
.github/ISSUE_TEMPLATE/issue.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Issue Report
|
||||
description: Report an issue
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this form! Please make sure to fill all fields, including the Title above.
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
|
||||
options:
|
||||
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://ersatztv.org/contact) first to troubleshoot with volunteers before creating a report.
|
||||
required: true
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/ErsatzTV/ErsatzTV/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I'm using an up to date version of ErsatzTV (full release or develop release); We generally do not support previous older versions. If possible, please update to the latest version before opening an issue.
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, please create separate reports for each one.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
Description of the problem or issue here.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro-steps
|
||||
attributes:
|
||||
label: Steps to reproduce the problem.
|
||||
description: |
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
3. Step 3
|
||||
|
||||
If this is a playback issue, follow these steps and post the resulting zip:
|
||||
1. Search for the required content using the search bar.
|
||||
2. Use the overflow/three dots menu on the content and select Troubleshoot Playback.
|
||||
3. Select the appropriate Playback Settings that trigger the undesired behavior.
|
||||
4. Click Play to start playback.
|
||||
5. Repeat steps 3 and 4 until the undesired behavior is reproduced.
|
||||
6. Click Download Results to have ErsatzTV collect relevant troubleshooting logs (ffmpeg log, ffmpeg profile, hardware capabilities, media info, etc) and compress them in a zip file.
|
||||
7. Attach the zip to this field.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: What is the current _bug_ behavior?
|
||||
description: Write down the incorrect behavior that currently happens after following the reproduction steps.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: What is the expected _correct_ behavior?
|
||||
description: Write down the correct expected behavior that is supposed to happen after following the reproduction steps.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Specify full version
|
||||
description: Provide the full version of ErsatzTV, which can be found below the left menu.
|
||||
placeholder: |
|
||||
25.5.0-bd695412-docker-amd64
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any additional information that might be useful to this issue.
|
||||
202
.github/workflows/artifacts.yml
vendored
202
.github/workflows/artifacts.yml
vendored
@@ -25,18 +25,26 @@ on:
|
||||
required: true
|
||||
gh_token:
|
||||
required: true
|
||||
azure_client_id:
|
||||
required: true
|
||||
azure_tenant_id:
|
||||
required: true
|
||||
azure_subscription_id:
|
||||
required: true
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
jobs:
|
||||
build_and_upload_mac:
|
||||
name: Mac Build & Upload
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-13
|
||||
- os: macos-14
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-13
|
||||
- os: macos-14
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
steps:
|
||||
@@ -46,8 +54,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -71,8 +81,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -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 net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.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 net10.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
|
||||
shell: bash
|
||||
@@ -124,22 +134,20 @@ jobs:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: |
|
||||
*${{ matrix.target }}.dmg
|
||||
assets: "*${{ matrix.target }}.dmg"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.dmg
|
||||
files: "${{ env.RELEASE_NAME }}.dmg"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
build_and_upload:
|
||||
name: Build & Upload
|
||||
|
||||
build_and_upload_linux:
|
||||
name: Build & Upload Linux
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -152,20 +160,19 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm
|
||||
- os: ubuntu-latest
|
||||
- os: ubuntu-24.04-arm
|
||||
kind: linux
|
||||
target: linux-arm64
|
||||
- os: windows-latest
|
||||
kind: windows
|
||||
target: win-x64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET Core
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -173,14 +180,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target }}"
|
||||
|
||||
- uses: suisei-cn/actions-download-file@v1.3.0
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
id: downloadffmpeg
|
||||
name: Download ffmpeg
|
||||
with:
|
||||
url: "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-06-12-14-05/ffmpeg-n7.1.1-22-g0f1fe3d153-win64-gpl-7.1.zip"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -190,31 +189,12 @@ jobs:
|
||||
|
||||
# Build everything
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -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 net8.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.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 net10.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
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
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
|
||||
|
||||
# Download ffmpeg
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
fi
|
||||
|
||||
# Pack files
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
else
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
fi
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
|
||||
# Delete output directory
|
||||
rm -r "$release_name"
|
||||
@@ -226,17 +206,131 @@ jobs:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: |
|
||||
*${{ matrix.target }}.zip
|
||||
*${{ matrix.target }}.tar.gz
|
||||
assets: "*${{ matrix.target }}.tar.gz"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.zip
|
||||
${{ env.RELEASE_NAME }}.tar.gz
|
||||
files: "${{ env.RELEASE_NAME }}.tar.gz"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
|
||||
build_dotnet_windows:
|
||||
name: Build dotnet for Windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "win-x64"
|
||||
|
||||
- name: Build dotnet projects
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Upload .NET Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dotnet-windows-build
|
||||
path: |
|
||||
scanner/
|
||||
main/
|
||||
retention-days: 1
|
||||
|
||||
package_and_upload_windows:
|
||||
name: Package & Upload Windows
|
||||
runs-on: windows-latest
|
||||
needs: build_dotnet_windows
|
||||
steps:
|
||||
- name: Download dotnet artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dotnet-windows-build
|
||||
path: dotnet-build
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.azure_client_id }}
|
||||
tenant-id: ${{ secrets.azure_tenant_id }}
|
||||
subscription-id: ${{ secrets.azure_subscription_id }}
|
||||
enable-AzPSSession: true
|
||||
|
||||
- name: Sign dotnet artifacts
|
||||
uses: azure/trusted-signing-action@v0
|
||||
with:
|
||||
endpoint: https://eus.codesigning.azure.net/
|
||||
trusted-signing-account-name: ArtifactSigning
|
||||
certificate-profile-name: ErsatzTV
|
||||
files-folder: ${{ github.workspace }}/dotnet-build
|
||||
files-folder-recurse: true
|
||||
files-folder-filter: ErsatzTV.exe,ErsatzTV.Scanner.exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Download rust launcher
|
||||
uses: suisei-cn/actions-download-file@v1.3.0
|
||||
with:
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-Windows/releases/download/v1.0.0/ErsatzTV-Windows.exe"
|
||||
target: rust-build/
|
||||
|
||||
- name: Download ffmpeg
|
||||
uses: suisei-cn/actions-download-file@v1.3.0
|
||||
id: downloadffmpeg
|
||||
with:
|
||||
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/
|
||||
|
||||
- name: Package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-win-x64"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
mkdir "$release_name"
|
||||
|
||||
mv dotnet-build/scanner/* "$release_name/"
|
||||
mv dotnet-build/main/* "$release_name/"
|
||||
|
||||
# dotnet shouldn't copy the resources here, but it does
|
||||
rm -rf "$release_name/Resources"
|
||||
|
||||
mv rust-build/ErsatzTV-Windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
|
||||
(cd "${release_name}" && 7z a "../${release_name}.zip" .)
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: "*win-x64.zip"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: "${{ env.RELEASE_NAME }}.zip"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -46,7 +46,11 @@ jobs:
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_and_push:
|
||||
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
build_images:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
|
||||
132
.github/workflows/docker.yml
vendored
132
.github/workflows/docker.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build & Publish to Docker Hub
|
||||
name: Build & Publish to Docker Hub
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
@@ -20,33 +20,28 @@ on:
|
||||
docker_hub_access_token:
|
||||
required: true
|
||||
jobs:
|
||||
build_and_push:
|
||||
name: Build & Publish
|
||||
runs-on: ubuntu-latest
|
||||
build_images:
|
||||
name: Build ${{ matrix.name }} image
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: base
|
||||
- name: amd64
|
||||
os: ubuntu-latest
|
||||
path: ''
|
||||
suffix: ''
|
||||
qemu: false
|
||||
- name: nvidia
|
||||
path: 'nvidia/'
|
||||
suffix: '-nvidia'
|
||||
qemu: false
|
||||
- name: vaapi
|
||||
path: 'vaapi/'
|
||||
suffix: '-vaapi'
|
||||
qemu: false
|
||||
suffix: '-amd64'
|
||||
platform: 'linux/amd64'
|
||||
- name: arm32v7
|
||||
os: ubuntu-latest
|
||||
path: 'arm32v7/'
|
||||
suffix: '-arm'
|
||||
qemu: true
|
||||
platform: 'linux/arm/v7'
|
||||
- name: arm64
|
||||
os: ubuntu-24.04-arm
|
||||
path: 'arm64/'
|
||||
suffix: '-arm64'
|
||||
qemu: true
|
||||
platform: 'linux/arm64'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -54,12 +49,11 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
if: ${{ matrix.name == 'arm32v7' }}
|
||||
uses: docker/setup-qemu-action@v3
|
||||
if: ${{ matrix.qemu == true }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: docker-buildx
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
@@ -74,52 +68,74 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
platforms: ${{ matrix.platform }}
|
||||
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 }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
|
||||
outputs: |
|
||||
type=image,name=jasongdove/ersatztv,name-canonical=true,push-by-digest=true
|
||||
type=image,name=ghcr.io/ersatztv/ersatztv,name-canonical=true,push-by-digest=true
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
platforms: 'linux/arm64'
|
||||
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 }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm64' }}
|
||||
- name: Save digest to artifact
|
||||
run: echo ${{ steps.build.outputs.digest }} > digest.txt
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
platforms: 'linux/arm/v7'
|
||||
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 }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm32v7' }}
|
||||
name: digest-${{ matrix.name }}
|
||||
path: digest.txt
|
||||
|
||||
merge_manifests:
|
||||
name: Merge Manifests
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_images
|
||||
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: Download all digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: digests/
|
||||
|
||||
- name: Read digests from artifacts
|
||||
id: digests
|
||||
run: |
|
||||
AMD64_HASH=$(cat digests/digest-amd64/digest.txt)
|
||||
ARM32V7_HASH=$(cat digests/digest-arm32v7/digest.txt)
|
||||
ARM64_HASH=$(cat digests/digest-arm64/digest.txt)
|
||||
|
||||
DOCKER_HUB_DIGESTS="jasongdove/ersatztv@${AMD64_HASH} jasongdove/ersatztv@${ARM64_HASH} jasongdove/ersatztv@${ARM32V7_HASH}"
|
||||
GHCR_DIGESTS="ghcr.io/ersatztv/ersatztv@${AMD64_HASH} ghcr.io/ersatztv/ersatztv@${ARM64_HASH} ghcr.io/ersatztv/ersatztv@${ARM32V7_HASH}"
|
||||
|
||||
echo "docker_hub_digests=${DOCKER_HUB_DIGESTS}" >> $GITHUB_OUTPUT
|
||||
echo "ghcr_digests=${GHCR_DIGESTS}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
docker manifest create jasongdove/ersatztv:${{ inputs.base_version }} ${{ steps.digests.outputs.docker_hub_digests }}
|
||||
docker manifest push jasongdove/ersatztv:${{ inputs.base_version }}
|
||||
docker manifest create jasongdove/ersatztv:${{ inputs.tag_version }} ${{ steps.digests.outputs.docker_hub_digests }}
|
||||
docker manifest push jasongdove/ersatztv:${{ inputs.tag_version }}
|
||||
|
||||
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }} ${{ steps.digests.outputs.ghcr_digests }}
|
||||
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}
|
||||
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }} ${{ steps.digests.outputs.ghcr_digests }}
|
||||
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}
|
||||
|
||||
143
.github/workflows/pr.yml
vendored
143
.github/workflows/pr.yml
vendored
@@ -2,59 +2,8 @@
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
build_and_test_windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- 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
|
||||
|
||||
- name: Build Windows
|
||||
run: |
|
||||
cd ErsatzTV-Windows
|
||||
cargo build --release --all-features
|
||||
build_and_test_linux:
|
||||
build_and_analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- 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
|
||||
build_and_test_mac:
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
@@ -62,8 +11,96 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- 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 /p:EnableThreadingAnalyzers=true
|
||||
build_and_test_windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- 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
|
||||
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 dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- 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 dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -41,7 +41,10 @@ jobs:
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_and_push:
|
||||
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
build_images:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,6 +3,9 @@
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
@@ -40,6 +43,7 @@ msbuild.wrn
|
||||
core
|
||||
|
||||
scripts/generate-api-sdk/swagger.json
|
||||
scripts/download-test-content.sh
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
|
||||
56
.mcp.json
Normal file
56
.mcp.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"docker-mcp": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-docker"
|
||||
],
|
||||
"env": {
|
||||
"DOCKER_HOST": "ssh://timothy@192.168.1.99"
|
||||
}
|
||||
},
|
||||
"ssh-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"ssh-mcp",
|
||||
"--",
|
||||
"--host=192.168.1.99",
|
||||
"--user=timothy"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"gitea": {
|
||||
"command": "gitea-mcp-server",
|
||||
"args": [
|
||||
"-t", "stdio",
|
||||
"-host", "http://192.168.1.95:3000",
|
||||
"-token", "8341af0733ab9ce084ea7adf38b76aa9ebc3bd67"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"csharp-lsp": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"run",
|
||||
"--project", "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp",
|
||||
"-c", "Release"
|
||||
],
|
||||
"env": {
|
||||
"PATH": "/usr/local/share/dotnet:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
}
|
||||
},
|
||||
"nuget": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"dnx",
|
||||
"NuGet.Mcp.Server",
|
||||
"--source", "https://api.nuget.org/v3/index.json",
|
||||
"--yes"
|
||||
],
|
||||
"env": {
|
||||
"DOTNET_ROOT": "/usr/local/share/dotnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1003
CHANGELOG.md
1003
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# ErsatzTV Fork
|
||||
|
||||
Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https://github.com/ErsatzTV/ErsatzTV) after upstream archival (Feb 2026, v26.3.0). Our fork lives on [Gitea](http://192.168.1.95:3000/timothy/ersatztv).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Language**: C# / .NET 10, Blazor Server UI (MudBlazor)
|
||||
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
|
||||
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
|
||||
- **Functional C#**: Language Ext (Option, Either monads throughout)
|
||||
|
||||
### Project Layout
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `ErsatzTV/` | ASP.NET Core host, Blazor pages, API controllers, DI setup |
|
||||
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
|
||||
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
|
||||
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
|
||||
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
|
||||
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
|
||||
| `ErsatzTV.Scanner/` | Media library scanning |
|
||||
|
||||
### Key Files
|
||||
|
||||
- **M3U generation**: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs` → `ToM3U()`
|
||||
- **XMLTV generation**: `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`
|
||||
- **IPTV controller**: `ErsatzTV/Controllers/IptvController.cs` — `/iptv/*` routes
|
||||
- **Logo generation**: `ErsatzTV.Core/Images/ChannelLogoGenerator.cs`
|
||||
- **Channel entities**: `ErsatzTV.Core/Domain/Channel.cs`
|
||||
- **DB context**: `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
|
||||
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
|
||||
- **Image**: `ghcr.io/ersatztv/ersatztv:latest` (to be replaced with our fork's image)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build ErsatzTV.sln
|
||||
|
||||
# Run locally (needs FFmpeg in PATH)
|
||||
dotnet run --project ErsatzTV
|
||||
|
||||
# Docker build
|
||||
docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Follow existing MediatR CQRS pattern for new features
|
||||
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
|
||||
- Keep Blazor pages thin — delegate to MediatR handlers
|
||||
- Test with xUnit (existing test projects)
|
||||
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
|
||||
|
||||
## Task Completion Protocol
|
||||
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
|
||||
|
||||
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
|
||||
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
|
||||
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
|
||||
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
|
||||
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
|
||||
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
|
||||
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
|
||||
|
||||
**ersatztv does NOT own**:
|
||||
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
|
||||
- NFS mounts, Ansible, DNS, networking → server-management
|
||||
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
|
||||
- Jellyfin skill → server-management (symlinked)
|
||||
|
||||
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
|
||||
|
||||
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
|
||||
|
||||
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
|
||||
|
||||
**Full cross-project rules**: `~/homelab-docs/Operations/Project Boundaries.md` (https://docs.tblindustries.be).
|
||||
**ErsatzTV docs**: `~/homelab-docs/Docker/ErsatzTV.md` + project-local `docs/` (fork strategy, channels, M3U/XMLTV).
|
||||
@@ -2,5 +2,6 @@
|
||||
<PropertyGroup>
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
15
Directory.Build.targets
Normal file
15
Directory.Build.targets
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.VisualStudio.Threading.Analyzers"
|
||||
Version="17.14.15"
|
||||
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
2
ErsatzTV-Windows/.gitignore
vendored
2
ErsatzTV-Windows/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
target/
|
||||
|
||||
1035
ErsatzTV-Windows/Cargo.lock
generated
1035
ErsatzTV-Windows/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
[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"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,6 +0,0 @@
|
||||
use windres::Build;
|
||||
|
||||
fn main() {
|
||||
static_vcruntime::metabuild();
|
||||
Build::new().compile("ersatztv_windows.rc").unwrap();
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
id ICON "ersatztv.ico"
|
||||
ersatztv-icon ICON "ersatztv.ico"
|
||||
@@ -1,109 +0,0 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use special_folder::SpecialFolder;
|
||||
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 _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("/C")
|
||||
.arg("start")
|
||||
.arg("http://localhost:8409")
|
||||
.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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Submodule ErsatzTV-macOS updated: bf18a5e4a4...8dbe1e22f2
@@ -29,9 +29,10 @@ internal static class Mapper
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
|
||||
return languages
|
||||
.Map(
|
||||
lang => allCultures.Filter(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
|
||||
.Map(lang => allCultures.Filter(ci => string.Equals(
|
||||
ci.ThreeLetterISOLanguageName,
|
||||
lang,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
.Flatten()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Artists.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
|
||||
public class GetArtistByIdHandler(
|
||||
IArtistRepository artistRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ILanguageCodeService languageCodeService)
|
||||
: IRequestHandler<GetArtistById, Option<ArtistViewModel>>
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public GetArtistByIdHandler(IArtistRepository artistRepository, ISearchRepository searchRepository)
|
||||
{
|
||||
_artistRepository = artistRepository;
|
||||
_searchRepository = searchRepository;
|
||||
}
|
||||
|
||||
public async Task<Option<ArtistViewModel>> Handle(
|
||||
GetArtistById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Artist> maybeArtist = await _artistRepository.GetArtist(request.ArtistId);
|
||||
Option<Artist> maybeArtist = await artistRepository.GetArtist(request.ArtistId);
|
||||
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
|
||||
async artist =>
|
||||
{
|
||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
|
||||
List<string> mediaCodes = await searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = languageCodeService.GetAllLanguageCodes(mediaCodes);
|
||||
return ProjectToViewModel(artist, languageCodes);
|
||||
},
|
||||
() => Task.FromResult(Option<ArtistViewModel>.None));
|
||||
|
||||
17
ErsatzTV.Application/Artworks/ArtworkContentTypeModel.cs
Normal file
17
ErsatzTV.Application/Artworks/ArtworkContentTypeModel.cs
Normal file
@@ -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)}";
|
||||
}
|
||||
@@ -6,24 +6,25 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory) : IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
|
||||
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,
|
||||
GetArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try {
|
||||
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)
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.Id, cancellationToken)
|
||||
.MapT(Project);
|
||||
|
||||
|
||||
return artwork.ToEither(BaseError.New("Artwork not found"));
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -31,12 +32,11 @@ public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory) :
|
||||
}
|
||||
}
|
||||
|
||||
private static Artwork Project(Artwork artwork)
|
||||
{
|
||||
return new Artwork {
|
||||
private static Artwork Project(Artwork artwork) =>
|
||||
new()
|
||||
{
|
||||
Id = artwork.Id,
|
||||
Path = artwork.Path,
|
||||
ArtworkKind = artwork.ArtworkKind
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
10
ErsatzTV.Application/Channels/ChannelSortViewModel.cs
Normal file
10
ErsatzTV.Application/Channels/ChannelSortViewModel.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class ChannelSortViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Number { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string OriginalNumber { get; set; }
|
||||
public bool HasChanged => OriginalNumber != Number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record ChannelStreamingSpecsViewModel(
|
||||
int Height,
|
||||
int Width,
|
||||
int Bitrate,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
FFmpegProfileAudioFormat AudioFormat);
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using System.Net;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
@@ -10,10 +11,16 @@ public record ChannelViewModel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -22,7 +29,11 @@ public record ChannelViewModel(
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode)
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg)
|
||||
{
|
||||
public string WebEncodedName => WebUtility.UrlEncode(Name);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
@@ -9,10 +10,16 @@ public record CreateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -20,4 +27,8 @@ public record CreateChannel(
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -22,7 +23,7 @@ public class CreateChannelHandler(
|
||||
CancellationToken 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, cancellationToken);
|
||||
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||
}
|
||||
|
||||
@@ -35,63 +36,94 @@ public class CreateChannelHandler(
|
||||
return new CreateChannelResult(channel.Id);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
|
||||
(ValidateName(request), await ValidateNumber(dbContext, request),
|
||||
await FFmpegProfileMustExist(dbContext, request),
|
||||
await WatermarkMustExist(dbContext, request),
|
||||
await FillerPresetMustExist(dbContext, request))
|
||||
.Apply(
|
||||
(
|
||||
name,
|
||||
number,
|
||||
ffmpegProfileId,
|
||||
watermarkId,
|
||||
fillerPresetId) =>
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) =>
|
||||
(ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await WatermarkMustExist(dbContext, request, cancellationToken),
|
||||
await FillerPresetMustExist(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
|
||||
.Apply((
|
||||
name,
|
||||
number,
|
||||
ffmpegProfileId,
|
||||
watermarkId,
|
||||
fillerPresetId,
|
||||
_) =>
|
||||
{
|
||||
var artwork = new List<Artwork>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
|
||||
{
|
||||
var artwork = new List<Artwork>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Logo))
|
||||
string logo = request.Logo.Path;
|
||||
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
|
||||
{
|
||||
artwork.Add(
|
||||
new Artwork
|
||||
{
|
||||
Path = request.Logo,
|
||||
ArtworkKind = ArtworkKind.Logo,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = DateTime.UtcNow
|
||||
});
|
||||
logo = logo.Replace("iptv/logos/", string.Empty);
|
||||
}
|
||||
|
||||
var channel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
Name = name,
|
||||
Number = number,
|
||||
Group = request.Group,
|
||||
Categories = request.Categories,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
ProgressMode = request.ProgressMode,
|
||||
StreamingMode = request.StreamingMode,
|
||||
Artwork = artwork,
|
||||
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle = request.PreferredAudioTitle,
|
||||
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
|
||||
SubtitleMode = request.SubtitleMode,
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
|
||||
SongVideoMode = request.SongVideoMode
|
||||
};
|
||||
artwork.Add(
|
||||
new Artwork
|
||||
{
|
||||
Path = logo,
|
||||
ArtworkKind = ArtworkKind.Logo,
|
||||
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType)
|
||||
? request.Logo.ContentType
|
||||
: null,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
foreach (int id in watermarkId)
|
||||
{
|
||||
channel.WatermarkId = id;
|
||||
}
|
||||
var channel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
Name = name,
|
||||
Number = number,
|
||||
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
|
||||
Group = request.Group,
|
||||
Categories = request.Categories,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
SlugSeconds = request.SlugSeconds,
|
||||
PlayoutSource = request.PlayoutSource,
|
||||
PlayoutMode = request.PlayoutMode,
|
||||
MirrorSourceChannelId = request.MirrorSourceChannelId,
|
||||
PlayoutOffset = request.PlayoutOffset,
|
||||
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,
|
||||
TranscodeMode = request.TranscodeMode,
|
||||
IdleBehavior = request.IdleBehavior,
|
||||
IsEnabled = request.IsEnabled,
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg
|
||||
};
|
||||
|
||||
foreach (int id in fillerPresetId)
|
||||
{
|
||||
channel.FallbackFillerId = id;
|
||||
}
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
channel.PlayoutMode = ChannelPlayoutMode.Continuous;
|
||||
}
|
||||
else
|
||||
{
|
||||
channel.MirrorSourceChannelId = null;
|
||||
channel.PlayoutOffset = null;
|
||||
}
|
||||
|
||||
return channel;
|
||||
});
|
||||
foreach (int id in watermarkId)
|
||||
{
|
||||
channel.WatermarkId = id;
|
||||
}
|
||||
|
||||
foreach (int id in fillerPresetId)
|
||||
{
|
||||
channel.FallbackFillerId = id;
|
||||
}
|
||||
|
||||
return channel;
|
||||
});
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
|
||||
createChannel.NotEmpty(c => c.Name)
|
||||
@@ -99,10 +131,11 @@ public class CreateChannelHandler(
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Channel> maybeExistingChannel = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number);
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number, cancellationToken);
|
||||
return maybeExistingChannel.Match<Validation<BaseError, string>>(
|
||||
_ => BaseError.New("Channel number must be unique"),
|
||||
() =>
|
||||
@@ -118,9 +151,10 @@ public class CreateChannelHandler(
|
||||
|
||||
private static Task<Validation<BaseError, int>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel) =>
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.CountAsync(p => p.Id == createChannel.FFmpegProfileId)
|
||||
.CountAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken)
|
||||
.Map(Optional)
|
||||
.Filter(c => c > 0)
|
||||
.MapT(_ => createChannel.FFmpegProfileId)
|
||||
@@ -128,7 +162,8 @@ public class CreateChannelHandler(
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (createChannel.WatermarkId is null)
|
||||
{
|
||||
@@ -136,7 +171,7 @@ public class CreateChannelHandler(
|
||||
}
|
||||
|
||||
return await dbContext.ChannelWatermarks
|
||||
.CountAsync(w => w.Id == createChannel.WatermarkId)
|
||||
.CountAsync(w => w.Id == createChannel.WatermarkId, cancellationToken)
|
||||
.Map(Optional)
|
||||
.Filter(c => c > 0)
|
||||
.MapT(_ => Optional(createChannel.WatermarkId))
|
||||
@@ -145,7 +180,8 @@ public class CreateChannelHandler(
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (createChannel.FallbackFillerId is null)
|
||||
{
|
||||
@@ -154,12 +190,53 @@ public class CreateChannelHandler(
|
||||
|
||||
return await dbContext.FillerPresets
|
||||
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
|
||||
.CountAsync(w => w.Id == createChannel.FallbackFillerId)
|
||||
.CountAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken)
|
||||
.Map(Optional)
|
||||
.Filter(c => c > 0)
|
||||
.MapT(_ => Optional(createChannel.FallbackFillerId))
|
||||
.Map(
|
||||
o => o.ToValidation<BaseError>(
|
||||
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
|
||||
.Map(o => o.ToValidation<BaseError>(
|
||||
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (createChannel.PlayoutSource is not ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
Option<Channel> maybeMirrorSource = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(
|
||||
c => c.Id == createChannel.MirrorSourceChannelId,
|
||||
c => c.Id == createChannel.MirrorSourceChannelId,
|
||||
cancellationToken);
|
||||
|
||||
if (maybeMirrorSource.IsNone)
|
||||
{
|
||||
return BaseError.New("Mirror source channel does not exist.");
|
||||
}
|
||||
|
||||
foreach (var mirrorSource in maybeMirrorSource)
|
||||
{
|
||||
if (mirrorSource.PlayoutSource is not ChannelPlayoutSource.Generated)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Mirror source channel {mirrorSource.Name} must use generated playout source");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (TimeSpan playoutOffset in Optional(createChannel.PlayoutOffset))
|
||||
{
|
||||
if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12))
|
||||
{
|
||||
return BaseError.New("Playout offset must not be greater than 12 hours");
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -12,26 +12,26 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public DeleteChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IFileSystem fileSystem,
|
||||
ISearchTargets searchTargets)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
_fileSystem = fileSystem;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request);
|
||||
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_localFileSystem.FileExists(cacheFile))
|
||||
if (_fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
@@ -57,10 +57,11 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteChannel deleteChannel)
|
||||
DeleteChannel deleteChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId);
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId, cancellationToken);
|
||||
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using System.Xml;
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core;
|
||||
@@ -11,6 +12,7 @@ using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
@@ -25,6 +27,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RefreshChannelDataHandler> _logger;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
@@ -32,12 +35,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
public RefreshChannelDataHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<RefreshChannelDataHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
_configElementRepository = configElementRepository;
|
||||
_logger = logger;
|
||||
@@ -45,207 +50,278 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
|
||||
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
string movieTemplateFileName = GetMovieTemplateFileName();
|
||||
string episodeTemplateFileName = GetEpisodeTemplateFileName();
|
||||
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
|
||||
string songTemplateFileName = GetSongTemplateFileName();
|
||||
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
|
||||
if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null ||
|
||||
songTemplateFileName is null || otherVideoTemplateFileName is null)
|
||||
try
|
||||
{
|
||||
return;
|
||||
}
|
||||
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
|
||||
|
||||
var minifier = new XmlMinifier(
|
||||
new XmlMinificationSettings
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
int hiddenCount = await dbContext.Channels
|
||||
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
|
||||
.CountAsync(cancellationToken);
|
||||
if (hiddenCount > 0)
|
||||
{
|
||||
MinifyWhitespace = true,
|
||||
RemoveXmlComments = true,
|
||||
CollapseTagsWithoutContent = true
|
||||
});
|
||||
|
||||
var templateContext = new XmlTemplateContext();
|
||||
|
||||
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
|
||||
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
|
||||
|
||||
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
|
||||
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
|
||||
|
||||
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
|
||||
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
|
||||
|
||||
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
|
||||
var songTemplate = Template.Parse(songText, songTemplateFileName);
|
||||
|
||||
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
|
||||
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Directors)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
int daysToBuild = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
|
||||
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
switch (playout.ProgramSchedulePlayoutType)
|
||||
{
|
||||
case ProgramSchedulePlayoutType.Flood:
|
||||
case ProgramSchedulePlayoutType.Yaml:
|
||||
var floodSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
floodSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.Block:
|
||||
var blockSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
await WriteBlockPlayoutXml(
|
||||
request,
|
||||
blockSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.ExternalJson:
|
||||
var externalJsonSorted = (await CollectExternalJsonItems(playout.ExternalJsonFile))
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
externalJsonSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
break;
|
||||
File.Delete(targetFile);
|
||||
return;
|
||||
}
|
||||
|
||||
string movieTemplateFileName = GetMovieTemplateFileName();
|
||||
string episodeTemplateFileName = GetEpisodeTemplateFileName();
|
||||
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
|
||||
string songTemplateFileName = GetSongTemplateFileName();
|
||||
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
|
||||
string remoteStreamTemplateFileName = GetRemoteStreamTemplateFileName();
|
||||
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
|
||||
musicVideoTemplateFileName is null ||
|
||||
songTemplateFileName is null || otherVideoTemplateFileName is null ||
|
||||
remoteStreamTemplateFileName is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var minifier = new XmlMinifier(
|
||||
new XmlMinificationSettings
|
||||
{
|
||||
MinifyWhitespace = true,
|
||||
RemoveXmlComments = true,
|
||||
CollapseTagsWithoutContent = true
|
||||
});
|
||||
|
||||
var templateContext = new XmlTemplateContext();
|
||||
|
||||
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
|
||||
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
|
||||
|
||||
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
|
||||
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
|
||||
|
||||
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
|
||||
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
|
||||
|
||||
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
|
||||
var songTemplate = Template.Parse(songText, songTemplateFileName);
|
||||
|
||||
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
|
||||
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
|
||||
|
||||
string remoteStreamText = await File.ReadAllTextAsync(remoteStreamTemplateFileName, cancellationToken);
|
||||
var remoteStreamTemplate = Template.Parse(remoteStreamText, remoteStreamTemplateFileName);
|
||||
|
||||
TimeSpan playoutOffset = TimeSpan.Zero;
|
||||
string mirrorChannelNumber = null;
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Include(c => c.MirrorSourceChannel)
|
||||
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null)
|
||||
.SelectOneAsync(
|
||||
c => c.Number == request.ChannelNumber,
|
||||
c => c.Number == request.ChannelNumber,
|
||||
cancellationToken);
|
||||
foreach (Channel channel in maybeChannel)
|
||||
{
|
||||
mirrorChannelNumber = channel.MirrorSourceChannel.Number;
|
||||
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero;
|
||||
}
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Directors)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
int daysToBuild = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
|
||||
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
switch (playout.ScheduleKind)
|
||||
{
|
||||
case PlayoutScheduleKind.Classic:
|
||||
case PlayoutScheduleKind.Sequential:
|
||||
case PlayoutScheduleKind.Scripted:
|
||||
var floodSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
foreach (var item in floodSorted)
|
||||
{
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
floodSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
case PlayoutScheduleKind.Block:
|
||||
var blockSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
foreach (var item in blockSorted)
|
||||
{
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WriteBlockPlayoutXml(
|
||||
request,
|
||||
blockSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
case PlayoutScheduleKind.ExternalJson:
|
||||
var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile))
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
foreach (var item in externalJsonSorted)
|
||||
{
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
externalJsonSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
|
||||
private async Task WritePlayoutXml(
|
||||
@@ -257,11 +333,13 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
Template musicVideoTemplate,
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
Template remoteStreamTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml)
|
||||
XmlWriter xml,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
XmltvTimeZone xmltvTimeZone = await _configElementRepository
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone)
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
|
||||
.IfNoneAsync(XmltvTimeZone.Local);
|
||||
|
||||
// skip all filler that isn't pre-roll
|
||||
@@ -287,24 +365,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.Tail or FillerKind.Fallback or FillerKind.DecoDefault))
|
||||
or FillerKind.PostRoll or FillerKind.Tail
|
||||
or FillerKind.Fallback or FillerKind.DecoDefault))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
int customShowId = -1;
|
||||
if (displayItem.MediaItem is Episode ep)
|
||||
{
|
||||
customShowId = ep.Season.ShowId;
|
||||
}
|
||||
|
||||
bool isSameCustomShow = hasCustomTitle;
|
||||
for (int x = j; x <= finishIndex; x++)
|
||||
{
|
||||
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
|
||||
customShowId == e.Season.ShowId;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
@@ -342,6 +408,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
|
||||
@@ -349,7 +416,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteBlockPlayoutXml(
|
||||
private async Task WriteBlockPlayoutXml(
|
||||
RefreshChannelData request,
|
||||
List<PlayoutItem> sorted,
|
||||
XmlTemplateContext templateContext,
|
||||
@@ -358,51 +425,110 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
Template musicVideoTemplate,
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
Template remoteStreamTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml)
|
||||
XmlWriter xml,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
XmltvTimeZone xmltvTimeZone = await _configElementRepository
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
|
||||
.IfNoneAsync(XmltvTimeZone.Local);
|
||||
|
||||
XmltvBlockBehavior xmltvBlockBehavior = await _configElementRepository
|
||||
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
|
||||
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
|
||||
|
||||
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
|
||||
foreach (var group in groups)
|
||||
{
|
||||
DateTime groupStart = group.Key.GuideStart!.Value;
|
||||
DateTime groupFinish = group.Key.GuideFinish!.Value;
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
|
||||
if (itemsToInclude.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime();
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
switch (xmltvBlockBehavior)
|
||||
{
|
||||
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
case XmltvBlockBehavior.UseActualTimes:
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
DateTimeOffset actualStart = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
item,
|
||||
start,
|
||||
stop,
|
||||
false,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
DateTimeOffset actualFinish = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
item,
|
||||
start,
|
||||
stop,
|
||||
false,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
}
|
||||
break;
|
||||
case XmltvBlockBehavior.SplitTimeEvenly:
|
||||
default:
|
||||
DateTime groupStart = group.Key.GuideStart!.Value;
|
||||
DateTime groupFinish = group.Key.GuideFinish!.Value;
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
item,
|
||||
start,
|
||||
stop,
|
||||
false,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,6 +545,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
Template musicVideoTemplate,
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
Template remoteStreamTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml)
|
||||
{
|
||||
@@ -480,6 +607,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
title,
|
||||
templateContext,
|
||||
otherVideoTemplate),
|
||||
RemoteStream templateRemoteStream => await ProcessRemoteStreamTemplate(
|
||||
request,
|
||||
templateRemoteStream,
|
||||
start,
|
||||
stop,
|
||||
hasCustomTitle,
|
||||
displayItem,
|
||||
title,
|
||||
templateContext,
|
||||
remoteStreamTemplate),
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
@@ -567,6 +704,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
showMetadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(showMetadata);
|
||||
string thumbnailPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
@@ -587,6 +725,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
|
||||
EpisodeArtworkUrl = artworkPath,
|
||||
EpisodeHasThumbnail = !string.IsNullOrWhiteSpace(thumbnailPath),
|
||||
EpisodeThumbnailUrl = thumbnailPath,
|
||||
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
|
||||
metadata.EpisodeNumber,
|
||||
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
|
||||
@@ -743,6 +883,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
metadata.Genres ??= [];
|
||||
metadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
@@ -757,6 +899,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
OtherVideoPlot = metadata.Plot,
|
||||
OtherVideoHasYear = metadata.Year.HasValue,
|
||||
OtherVideoYear = metadata.Year,
|
||||
OtherVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
|
||||
OtherVideoArtworkUrl = artworkPath,
|
||||
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
|
||||
OtherVideoContentRating = metadata.ContentRating
|
||||
@@ -772,18 +916,63 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
return Option<string>.None;
|
||||
}
|
||||
|
||||
private string GetMovieTemplateFileName()
|
||||
private static async Task<Option<string>> ProcessRemoteStreamTemplate(
|
||||
RefreshChannelData request,
|
||||
RemoteStream templateRemoteStream,
|
||||
string start,
|
||||
string stop,
|
||||
bool hasCustomTitle,
|
||||
PlayoutItem displayItem,
|
||||
string title,
|
||||
XmlTemplateContext templateContext,
|
||||
Template remoteStreamTemplate)
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "movie.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
foreach (RemoteStreamMetadata metadata in templateRemoteStream.RemoteStreamMetadata.HeadOrNone())
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_movie.sbntxt");
|
||||
metadata.Genres ??= [];
|
||||
metadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
ProgrammeStop = stop,
|
||||
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
|
||||
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
|
||||
request.ChannelNumber,
|
||||
HasCustomTitle = hasCustomTitle,
|
||||
displayItem.CustomTitle,
|
||||
RemoteStreamTitle = title,
|
||||
RemoteStreamHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
|
||||
RemoteStreamPlot = metadata.Plot,
|
||||
RemoteStreamHasYear = metadata.Year.HasValue,
|
||||
RemoteStreamYear = metadata.Year,
|
||||
RemoteStreamHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
|
||||
RemoteStreamArtworkUrl = artworkPath,
|
||||
RemoteStreamGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||
RemoteStreamHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
|
||||
RemoteStreamContentRating = metadata.ContentRating
|
||||
};
|
||||
|
||||
var scriptObject = new ScriptObject();
|
||||
scriptObject.Import(data);
|
||||
templateContext.PushGlobal(scriptObject);
|
||||
|
||||
return await remoteStreamTemplate.RenderAsync(templateContext);
|
||||
}
|
||||
|
||||
return Option<string>.None;
|
||||
}
|
||||
|
||||
private string GetMovieTemplateFileName()
|
||||
{
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"movie.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -797,16 +986,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetEpisodeTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "episode.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_episode.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"episode.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -820,16 +1005,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetMusicVideoTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "musicVideo.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_musicVideo.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"musicVideo.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -843,16 +1024,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetSongTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "song.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_song.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"song.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -866,16 +1043,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetOtherVideoTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "otherVideo.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_otherVideo.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"otherVideo.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -887,6 +1060,25 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
return templateFileName;
|
||||
}
|
||||
|
||||
private string GetRemoteStreamTemplateFileName()
|
||||
{
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"remoteStream.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate remote stream XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
templateFileName);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return templateFileName;
|
||||
}
|
||||
|
||||
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
|
||||
{
|
||||
string artworkPath = artwork.Path;
|
||||
@@ -942,6 +1134,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown remote stream]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
@@ -990,7 +1184,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
var result = new List<PlayoutItem>();
|
||||
|
||||
if (_localFileSystem.FileExists(path))
|
||||
if (_fileSystem.File.Exists(path))
|
||||
{
|
||||
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
|
||||
await File.ReadAllTextAsync(path));
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Data.Common;
|
||||
using System.IO.Abstractions;
|
||||
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;
|
||||
@@ -18,6 +20,7 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RefreshChannelListHandler> _logger;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
@@ -25,11 +28,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
public RefreshChannelListHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RefreshChannelListHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -43,13 +48,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
|
||||
}
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate channel list without template file {File}; please restart ErsatzTV",
|
||||
@@ -77,6 +82,9 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
|
||||
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),
|
||||
@@ -84,7 +92,8 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
ChannelNumber = channel.Number,
|
||||
ChannelName = channel.Name,
|
||||
ChannelCategories = GetCategories(channel.Categories),
|
||||
ChannelHasArtwork = !string.IsNullOrWhiteSpace(channel.ArtworkPath),
|
||||
ChannelHasExternalArtwork = hasExternalLogo,
|
||||
ChannelHasArtwork = hasLogo,
|
||||
ChannelArtworkPath = channel.ArtworkPath,
|
||||
ChannelNameEncoded = WebUtility.UrlEncode(channel.Name)
|
||||
};
|
||||
@@ -113,7 +122,7 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
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)
|
||||
where (C.Id in (select ChannelId from Playout) or C.MirrorSourceChannelId in (select ChannelId from Playout)) and C.IsEnabled = 1 and C.ShowInEPG = 1
|
||||
order by CAST(C.Number as double)";
|
||||
// TODO: this needs to be fixed for sqlite/mariadb
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
@@ -10,10 +11,16 @@ public record UpdateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -21,4 +28,8 @@ public record UpdateChannel(
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -24,17 +24,37 @@ public class UpdateChannelHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
Channel c,
|
||||
UpdateChannel update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// don't save mirror when playout exists
|
||||
if (c.Playouts.Count > 0)
|
||||
{
|
||||
update = update with
|
||||
{
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
MirrorSourceChannelId = null
|
||||
};
|
||||
}
|
||||
|
||||
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
|
||||
|
||||
c.Name = update.Name;
|
||||
c.Number = update.Number;
|
||||
c.SortNumber = double.Parse(update.Number, CultureInfo.InvariantCulture);
|
||||
c.Group = update.Group;
|
||||
c.Categories = update.Categories;
|
||||
c.FFmpegProfileId = update.FFmpegProfileId;
|
||||
c.SlugSeconds = update.SlugSeconds;
|
||||
c.StreamSelectorMode = update.StreamSelectorMode;
|
||||
c.StreamSelector = update.StreamSelector;
|
||||
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
|
||||
c.PreferredAudioTitle = update.PreferredAudioTitle;
|
||||
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
|
||||
@@ -42,80 +62,177 @@ public class UpdateChannelHandler(
|
||||
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
|
||||
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
|
||||
c.SongVideoMode = update.SongVideoMode;
|
||||
c.Artwork ??= new List<Artwork>();
|
||||
c.TranscodeMode = update.TranscodeMode;
|
||||
c.IdleBehavior = update.IdleBehavior;
|
||||
c.IsEnabled = update.IsEnabled;
|
||||
c.ShowInEpg = update.IsEnabled && update.ShowInEpg;
|
||||
c.Artwork ??= [];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo))
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
|
||||
{
|
||||
Option<Artwork> maybeLogo =
|
||||
Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo);
|
||||
string logo = update.Logo.Path;
|
||||
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
|
||||
{
|
||||
logo = logo.Replace("iptv/logos/", string.Empty);
|
||||
}
|
||||
|
||||
maybeLogo.Match(
|
||||
artwork =>
|
||||
Option<Artwork> maybeLogo = c.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo).HeadOrNone();
|
||||
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;
|
||||
artwork.DateUpdated = DateTime.UtcNow;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var artwork = new Artwork
|
||||
{
|
||||
Path = update.Logo,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = DateTime.UtcNow,
|
||||
ArtworkKind = ArtworkKind.Logo
|
||||
};
|
||||
c.Artwork.Add(artwork);
|
||||
});
|
||||
Path = logo,
|
||||
OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
|
||||
? update.Logo.ContentType
|
||||
: null,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = DateTime.UtcNow,
|
||||
ArtworkKind = ArtworkKind.Logo
|
||||
};
|
||||
c.Artwork.Add(artwork);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await dbContext.Entry(c)
|
||||
.Collection(channel => channel.Artwork)
|
||||
.LoadAsync(cancellationToken);
|
||||
|
||||
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.PlayoutSource = update.PlayoutSource;
|
||||
c.PlayoutMode = update.PlayoutMode;
|
||||
|
||||
if (c.PlayoutSource is ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
c.PlayoutMode = ChannelPlayoutMode.Continuous;
|
||||
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
|
||||
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
|
||||
}
|
||||
else
|
||||
{
|
||||
c.MirrorSourceChannelId = null;
|
||||
c.PlayoutOffset = null;
|
||||
}
|
||||
|
||||
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
|
||||
c.PlayoutOffset = update.PlayoutOffset;
|
||||
c.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
if (c.SubtitleMode != ChannelSubtitleMode.None)
|
||||
{
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id);
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, cancellationToken);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList());
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
if (hasEpgChange)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
|
||||
}
|
||||
|
||||
return ProjectToViewModel(c);
|
||||
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
|
||||
(await ChannelMustExist(dbContext, request), ValidateName(request),
|
||||
await ValidateNumber(dbContext, request))
|
||||
.Apply((channelToUpdate, _, _) => channelToUpdate);
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ChannelMustExist(dbContext, request, cancellationToken),
|
||||
ValidateName(request),
|
||||
await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
|
||||
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateChannel updateChannel) =>
|
||||
UpdateChannel updateChannel,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Channels
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId)
|
||||
.Include(c => c.Playouts)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Channel does not exist."));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
Option<Channel> maybeMirrorSource = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(
|
||||
c => c.Id == request.MirrorSourceChannelId,
|
||||
c => c.Id == request.MirrorSourceChannelId,
|
||||
cancellationToken);
|
||||
|
||||
if (maybeMirrorSource.IsNone)
|
||||
{
|
||||
return BaseError.New("Mirror source channel does not exist.");
|
||||
}
|
||||
|
||||
foreach (var mirrorSource in maybeMirrorSource)
|
||||
{
|
||||
if (mirrorSource.PlayoutSource is not ChannelPlayoutSource.Generated)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Mirror source channel {mirrorSource.Name} must use generated playout source");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (TimeSpan playoutOffset in Optional(request.PlayoutOffset))
|
||||
{
|
||||
if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12))
|
||||
{
|
||||
return BaseError.New("Playout offset must not be greater than 12 hours");
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(UpdateChannel updateChannel) =>
|
||||
updateChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||
TvContext dbContext,
|
||||
UpdateChannel updateChannel)
|
||||
UpdateChannel updateChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int matchId = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number)
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number, cancellationToken)
|
||||
.Match(c => c.Id, () => updateChannel.ChannelId);
|
||||
|
||||
if (matchId == updateChannel.ChannelId)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record UpdateChannelNumbers(List<ChannelSortViewModel> Channels) : IRequest<Option<BaseError>>;
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class UpdateChannelNumbersHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
: IRequestHandler<UpdateChannelNumbers, Option<BaseError>>
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
|
||||
var channelIds = numberUpdates.Keys;
|
||||
|
||||
List<Channel> channelsToUpdate = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// give every channel a non-conflicting number
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
channel.Number = $"-{channel.Id}";
|
||||
}
|
||||
|
||||
// save those changes
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// give every channel the proper new number
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
channel.Number = numberUpdates[channel.Id];
|
||||
if (double.TryParse(channel.Number, CultureInfo.InvariantCulture, out double sortNumber))
|
||||
{
|
||||
channel.SortNumber = sortNumber;
|
||||
}
|
||||
else
|
||||
{
|
||||
return BaseError.New($"Failed to parse channel number {channel.Number}");
|
||||
}
|
||||
}
|
||||
|
||||
// save those changes
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// commit the transaction
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
// update channel list and xmltv
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), cancellationToken);
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New("Failed to update channel numbers: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static ChannelViewModel ProjectToViewModel(Channel channel) =>
|
||||
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
@@ -13,19 +14,29 @@ internal static class Mapper
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfileId,
|
||||
channel.SlugSeconds,
|
||||
GetLogo(channel),
|
||||
channel.StreamSelectorMode,
|
||||
channel.StreamSelector,
|
||||
channel.PreferredAudioLanguageCode,
|
||||
channel.PreferredAudioTitle,
|
||||
channel.ProgressMode,
|
||||
channel.PlayoutSource,
|
||||
channel.PlayoutMode,
|
||||
channel.MirrorSourceChannelId,
|
||||
channel.PlayoutOffset,
|
||||
channel.StreamingMode,
|
||||
channel.WatermarkId,
|
||||
channel.FallbackFillerId,
|
||||
channel.Playouts?.Count ?? 0,
|
||||
playoutCount,
|
||||
channel.PreferredSubtitleLanguageCode,
|
||||
channel.SubtitleMode,
|
||||
channel.MusicVideoCreditsMode,
|
||||
channel.MusicVideoCreditsTemplate,
|
||||
channel.SongVideoMode);
|
||||
channel.SongVideoMode,
|
||||
channel.TranscodeMode,
|
||||
channel.IdleBehavior,
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
@@ -39,12 +50,30 @@ internal static class Mapper
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
|
||||
internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) =>
|
||||
new(resolution.Height, resolution.Width, bitrate);
|
||||
internal static ChannelStreamingSpecsViewModel ProjectToSpecsViewModel(Channel channel) =>
|
||||
new(
|
||||
channel.FFmpegProfile.Resolution.Height,
|
||||
channel.FFmpegProfile.Resolution.Width,
|
||||
(int)((channel.FFmpegProfile.VideoBitrate * 1000 + channel.FFmpegProfile.AudioBitrate * 1000) * 1.2),
|
||||
channel.FFmpegProfile.VideoFormat,
|
||||
channel.FFmpegProfile.VideoProfile,
|
||||
channel.FFmpegProfile.AudioFormat);
|
||||
|
||||
private static string GetLogo(Channel channel) =>
|
||||
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
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) =>
|
||||
channel.StreamingMode switch
|
||||
@@ -53,7 +82,6 @@ internal static class Mapper
|
||||
StreamingMode.TransportStreamHybrid => "MPEG-TS",
|
||||
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
||||
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(channel))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetAllChannels : IRequest<List<ChannelViewModel>>;
|
||||
public record GetAllChannels(bool ShowDisabled = true) : IRequest<List<ChannelViewModel>>;
|
||||
|
||||
@@ -5,17 +5,14 @@ using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
|
||||
public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetAllChannelsForApiHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public async Task<List<ChannelResponseModel>> Handle(
|
||||
GetAllChannelsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll()).Flatten();
|
||||
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
return channels.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetAllChannelsForSort : IRequest<List<ChannelSortViewModel>>;
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetAllChannelsForSortHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllChannelsForSort, List<ChannelSortViewModel>>
|
||||
{
|
||||
public async Task<List<ChannelSortViewModel>> Handle(
|
||||
GetAllChannelsForSort request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToSortViewModel)
|
||||
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)).ToList());
|
||||
}
|
||||
|
||||
private static ChannelSortViewModel ProjectToSortViewModel(Channel channel)
|
||||
=> new()
|
||||
{
|
||||
Id = channel.Id,
|
||||
Number = channel.Number,
|
||||
Name = channel.Name,
|
||||
OriginalNumber = channel.Number
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,31 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetAllChannelsHandler : IRequestHandler<GetAllChannels, List<ChannelViewModel>>
|
||||
public class GetAllChannelsHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetAllChannels, List<ChannelViewModel>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
|
||||
Optional(await _channelRepository.GetAll()).Flatten().Map(ProjectToViewModel).ToList();
|
||||
await channelRepository.GetAll(cancellationToken)
|
||||
.Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled)
|
||||
.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
|
||||
|
||||
private static int GetPlayoutsCount(Channel channel)
|
||||
{
|
||||
var result = 0;
|
||||
|
||||
if (channel.Playouts != null)
|
||||
{
|
||||
result += channel.Playouts.Count;
|
||||
}
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
|
||||
{
|
||||
result += channel.MirrorSourceChannel.Playouts.Count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,10 @@ using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
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) =>
|
||||
_channelRepository.GetChannel(request.Id)
|
||||
.MapT(ProjectToViewModel);
|
||||
channelRepository.GetChannel(request.Id)
|
||||
.MapT(c => ProjectToViewModel(c, 0));
|
||||
}
|
||||
|
||||
@@ -3,12 +3,9 @@ using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelByNumberHandler : IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
|
||||
public class GetChannelByNumberHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetChannelByNumberHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public Task<Option<ChannelViewModel>> Handle(GetChannelByNumber request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetByNumber(request.ChannelNumber).MapT(ProjectToViewModel);
|
||||
channelRepository.GetByNumber(request.ChannelNumber).MapT(c => ProjectToViewModel(c, 0));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelByPlayoutId(int PlayoutId) : IRequest<Option<ChannelViewModel>>;
|
||||
@@ -0,0 +1,21 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelByPlayoutId, Option<ChannelViewModel>>
|
||||
{
|
||||
public async Task<Option<ChannelViewModel>> Handle(
|
||||
GetChannelByPlayoutId request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.ThenInclude(c => c.Artwork)
|
||||
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken)
|
||||
.Map(p => ProjectToViewModel(p.Channel, 1));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.FFmpeg;
|
||||
|
||||
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<int>>;
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<FrameRate>>;
|
||||
|
||||
@@ -1,123 +1,118 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, Option<int>>
|
||||
public class GetChannelFramerateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<GetChannelFramerateHandler> logger)
|
||||
: IRequestHandler<GetChannelFramerate, Option<FrameRate>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILogger<GetChannelFramerateHandler> _logger;
|
||||
|
||||
public GetChannelFramerateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<GetChannelFramerateHandler> logger)
|
||||
public async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
FFmpegProfile ffmpegProfile = await dbContext.Channels
|
||||
.Filter(c => c.Number == request.ChannelNumber)
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Map(c => c.FFmpegProfile)
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
if (!ffmpegProfile.NormalizeFramerate)
|
||||
try
|
||||
{
|
||||
return Option<int>.None;
|
||||
}
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// TODO: expand to check everything in collection rather than what's scheduled?
|
||||
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
FFmpegProfile ffmpegProfile = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Filter(c => c.Number == request.ChannelNumber)
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Map(c => c.FFmpegProfile)
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.Filter(p => p.Channel.Number == request.ChannelNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
|
||||
.Flatten()
|
||||
.Map(mv => mv.RFrameRate)
|
||||
.ToList();
|
||||
|
||||
var distinct = frameRates.Distinct().ToList();
|
||||
if (distinct.Count > 1)
|
||||
{
|
||||
// TODO: something more intelligent than minimum framerate?
|
||||
int result = frameRates.Map(ParseFrameRate).Min();
|
||||
if (result < 24)
|
||||
if (!ffmpegProfile.NormalizeFramerate)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
24,
|
||||
result);
|
||||
|
||||
return 24;
|
||||
return Option<FrameRate>.None;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
result);
|
||||
return result;
|
||||
}
|
||||
// TODO: expand to check everything in collection rather than what's scheduled?
|
||||
logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
|
||||
if (distinct.Count != 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Image).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as RemoteStream).MediaVersions)
|
||||
.Filter(p => p.Channel.Number == request.ChannelNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
|
||||
.Flatten()
|
||||
.Map(mv => new FrameRate(mv.RFrameRate))
|
||||
.ToList();
|
||||
|
||||
var distinct = frameRates.Distinct().ToList();
|
||||
if (distinct.Count > 1)
|
||||
{
|
||||
// TODO: something more intelligent than minimum framerate?
|
||||
var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList();
|
||||
if (validFrameRates.Count > 0)
|
||||
{
|
||||
FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate);
|
||||
logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct.Map(fr => fr.RFrameRate),
|
||||
result.RFrameRate);
|
||||
return result;
|
||||
}
|
||||
|
||||
FrameRate minFrameRate = frameRates.MinBy(fr => fr.ParsedFrameRate);
|
||||
logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct.Map(fr => fr.RFrameRate),
|
||||
FrameRate.DefaultFrameRate.RFrameRate,
|
||||
minFrameRate.RFrameRate);
|
||||
|
||||
return FrameRate.DefaultFrameRate;
|
||||
}
|
||||
|
||||
if (distinct.Count != 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0].RFrameRate);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation(
|
||||
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Unexpected error checking frame rates on channel {ChannelNumber}",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private int ParseFrameRate(string frameRate)
|
||||
{
|
||||
if (!int.TryParse(frameRate, out int fr))
|
||||
{
|
||||
string[] split = (frameRate ?? string.Empty).Split("/");
|
||||
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
|
||||
{
|
||||
fr = (int)Math.Round(left / (double)right);
|
||||
}
|
||||
else
|
||||
{
|
||||
fr = 24;
|
||||
}
|
||||
}
|
||||
|
||||
return fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
@@ -8,35 +11,32 @@ using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
GetChannelGuide request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
.AsEnumerable()
|
||||
.Select(n => $"{n}.xml")
|
||||
.ToImmutableHashSet();
|
||||
|
||||
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!_localFileSystem.FileExists(channelsFile))
|
||||
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!fileSystem.File.Exists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
|
||||
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
|
||||
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
@@ -44,7 +44,7 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
accessTokenUri += $"&access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
@@ -53,22 +53,54 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
|
||||
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
{
|
||||
if (fileName.Contains("channels"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
try
|
||||
{
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
|
||||
channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// ignore this channel fragment
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// ignore this channel fragment
|
||||
}
|
||||
}
|
||||
|
||||
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
}
|
||||
|
||||
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = fileSystem.FileStream.New(
|
||||
fileName,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
return await reader.ReadToEndAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
|
||||
private static partial Regex EtvTagRegex();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<Li
|
||||
public GetChannelLineupHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
.Map(channels => channels.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
|
||||
_channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => channels.Where(c => c.IsEnabled)
|
||||
.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
|
||||
}
|
||||
|
||||
@@ -4,19 +4,15 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||
public class GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: 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);
|
||||
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)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
|
||||
.MapT(p => p.Channel.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,40 +4,36 @@ using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetChannelPlaylistHandler(IChannelRepository channelRepository) =>
|
||||
_channelRepository = channelRepository;
|
||||
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(
|
||||
channels => new ChannelPlaylist(
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels,
|
||||
request.UserAgent,
|
||||
request.AccessToken));
|
||||
.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)
|
||||
{
|
||||
var result = new List<Channel>();
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
if (!channel.IsEnabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (mode.ToLowerInvariant())
|
||||
{
|
||||
case "segmenter":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "segmenter-v2":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterV2;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "hls-direct":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
|
||||
result.Add(channel);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelResolutionAndBitrate(string ChannelNumber) : IRequest<Option<ResolutionAndBitrateViewModel>>;
|
||||
@@ -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 GetChannelStreamingSpecs(string ChannelNumber) : IRequest<Option<ChannelStreamingSpecsViewModel>>;
|
||||
@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelResolutionAndBitrateHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelResolutionAndBitrate, Option<ResolutionAndBitrateViewModel>>
|
||||
public class GetChannelStreamingSpecsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelStreamingSpecs, Option<ChannelStreamingSpecsViewModel>>
|
||||
{
|
||||
public async Task<Option<ResolutionAndBitrateViewModel>> Handle(
|
||||
GetChannelResolutionAndBitrate request,
|
||||
public async Task<Option<ChannelStreamingSpecsViewModel>> Handle(
|
||||
GetChannelStreamingSpecs request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
@@ -18,10 +18,8 @@ public class GetChannelResolutionAndBitrateHandler(IDbContextFactory<TvContext>
|
||||
.AsNoTracking()
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.ThenInclude(ff => ff.Resolution)
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
|
||||
|
||||
return maybeChannel.Map(c => Mapper.ProjectToViewModel(
|
||||
c.FFmpegProfile.Resolution,
|
||||
(int)((c.FFmpegProfile.VideoBitrate * 1000 + c.FFmpegProfile.AudioBitrate * 1000) * 1.2)));
|
||||
return maybeChannel.Map(Mapper.ProjectToSpecsViewModel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetSlugSecondsByChannelNumber(string ChannelNumber) : IRequest<Option<double>>;
|
||||
@@ -0,0 +1,17 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetSlugSecondsByChannelNumberHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetSlugSecondsByChannelNumber, Option<double>>
|
||||
{
|
||||
public async Task<Option<double>> Handle(GetSlugSecondsByChannelNumber request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Number == request.ChannelNumber, cancellationToken)
|
||||
.Map(c => Optional(c?.SlugSeconds));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record ResolutionAndBitrateViewModel(int Height, int Width, int Bitrate);
|
||||
@@ -10,5 +10,5 @@ public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementBy
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken) =>
|
||||
await _configElementRepository.Upsert(request.Key, request.Value);
|
||||
await _configElementRepository.Upsert(request.Key, request.Value, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateGeneralSettings(GeneralSettingsViewModel GeneralSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -16,16 +16,16 @@ public class UpdateLibraryRefreshIntervalHandler :
|
||||
UpdateLibraryRefreshInterval request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(
|
||||
_ => _configElementRepository.Upsert(
|
||||
ConfigElementKey.LibraryRefreshInterval,
|
||||
request.LibraryRefreshInterval))
|
||||
.MapT(_ => _configElementRepository.Upsert(
|
||||
ConfigElementKey.LibraryRefreshInterval,
|
||||
request.LibraryRefreshInterval,
|
||||
cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
|
||||
Optional(request.LibraryRefreshInterval)
|
||||
.Where(lri => lri is >= 0 and < 1_000_000)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Library refresh interval must be zero or greated")
|
||||
.ToValidation<BaseError>("Library refresh interval must be zero or greater")
|
||||
.AsTask();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateLoggingSettings(LoggingSettingsViewModel LoggingSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -4,12 +4,12 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSettings, Either<BaseError, Unit>>
|
||||
public class UpdateLoggingSettingsHandler : IRequestHandler<UpdateLoggingSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly LoggingLevelSwitches _loggingLevelSwitches;
|
||||
|
||||
public UpdateGeneralSettingsHandler(
|
||||
public UpdateLoggingSettingsHandler(
|
||||
LoggingLevelSwitches loggingLevelSwitches,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
@@ -18,33 +18,38 @@ public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSetting
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateGeneralSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.GeneralSettings);
|
||||
UpdateLoggingSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings, cancellationToken);
|
||||
|
||||
private async Task<Unit> ApplyUpdate(GeneralSettingsViewModel generalSettings)
|
||||
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.DefaultMinimumLogLevel);
|
||||
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = generalSettings.DefaultMinimumLogLevel;
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = loggingSettings.DefaultMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelScanning,
|
||||
generalSettings.ScanningMinimumLogLevel);
|
||||
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = generalSettings.ScanningMinimumLogLevel;
|
||||
loggingSettings.ScanningMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = loggingSettings.ScanningMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelScheduling,
|
||||
generalSettings.SchedulingMinimumLogLevel);
|
||||
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = generalSettings.SchedulingMinimumLogLevel;
|
||||
loggingSettings.SchedulingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = loggingSettings.SchedulingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelSearching,
|
||||
loggingSettings.SearchingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.SearchingLevelSwitch.MinimumLevel = loggingSettings.SearchingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelStreaming,
|
||||
generalSettings.StreamingMinimumLogLevel);
|
||||
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = generalSettings.StreamingMinimumLogLevel;
|
||||
loggingSettings.StreamingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = loggingSettings.StreamingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelHttp,
|
||||
generalSettings.HttpMinimumLogLevel);
|
||||
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = generalSettings.HttpMinimumLogLevel;
|
||||
loggingSettings.HttpMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = loggingSettings.HttpMinimumLogLevel;
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
@@ -32,24 +32,40 @@ public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSetting
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Unit> validation = await Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.PlayoutSettings));
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(
|
||||
dbContext,
|
||||
request.PlayoutSettings,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(TvContext dbContext, PlayoutSettingsViewModel playoutSettings)
|
||||
private async Task<Unit> ApplyUpdate(
|
||||
TvContext dbContext,
|
||||
PlayoutSettingsViewModel playoutSettings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, playoutSettings.DaysToBuild);
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.PlayoutDaysToBuild,
|
||||
playoutSettings.DaysToBuild,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.PlayoutSkipMissingItems,
|
||||
playoutSettings.SkipMissingItems);
|
||||
playoutSettings.SkipMissingItems,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.PlayoutScriptedScheduleTimeoutSeconds,
|
||||
playoutSettings.ScriptedScheduleTimeoutSeconds,
|
||||
cancellationToken);
|
||||
|
||||
// continue all playouts to proper number of days
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
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), cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateUiSettings(UiSettingsViewModel UiSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,28 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateUiSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<UpdateUiSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateUiSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await ApplyUpdate(request.UiSettings, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.PagesIsDarkMode,
|
||||
uiSettings.IsDarkMode,
|
||||
cancellationToken);
|
||||
|
||||
await configElementRepository.Upsert(ConfigElementKey.PagesLanguage, uiSettings.Language, cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ public class UpdateXmltvSettingsHandler(
|
||||
{
|
||||
int playoutDaysToBuild =
|
||||
await configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
|
||||
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild, cancellationToken)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
if (playoutDaysToBuild < request.XmltvSettings.DaysToBuild)
|
||||
@@ -29,19 +29,20 @@ public class UpdateXmltvSettingsHandler(
|
||||
$"XMLTV days to build ({request.XmltvSettings.DaysToBuild}) cannot be greater than Playout days to build ({playoutDaysToBuild})");
|
||||
}
|
||||
|
||||
return await ApplyUpdate(request.XmltvSettings);
|
||||
return await ApplyUpdate(request.XmltvSettings, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings)
|
||||
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone, cancellationToken);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild, cancellationToken);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvBlockBehavior, xmltvSettings.BlockBehavior, cancellationToken);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync())
|
||||
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync(cancellationToken))
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber));
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -2,11 +2,12 @@ using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GeneralSettingsViewModel
|
||||
public class LoggingSettingsViewModel
|
||||
{
|
||||
public LogEventLevel DefaultMinimumLogLevel { get; set; }
|
||||
public LogEventLevel ScanningMinimumLogLevel { get; set; }
|
||||
public LogEventLevel SchedulingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel SearchingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel StreamingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel HttpMinimumLogLevel { get; set; }
|
||||
}
|
||||
@@ -4,4 +4,5 @@ public class PlayoutSettingsViewModel
|
||||
{
|
||||
public int DaysToBuild { get; set; }
|
||||
public bool SkipMissingItems { get; set; }
|
||||
public int ScriptedScheduleTimeoutSeconds { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@ public class GetConfigElementByKeyHandler : IRequestHandler<GetConfigElementByKe
|
||||
public Task<Option<ConfigElementViewModel>> Handle(
|
||||
GetConfigElementByKey request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetConfigElement(request.Key).MapT(ProjectToViewModel);
|
||||
_configElementRepository.GetConfigElement(request.Key, cancellationToken).MapT(ProjectToViewModel);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetGeneralSettings : IRequest<GeneralSettingsViewModel>;
|
||||
@@ -11,6 +11,6 @@ public class GetLibraryRefreshIntervalHandler : IRequestHandler<GetLibraryRefres
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<int> Handle(GetLibraryRefreshInterval request, CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
|
||||
.Map(result => result.IfNone(6));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetLoggingSettings : IRequest<LoggingSettingsViewModel>;
|
||||
@@ -4,35 +4,49 @@ using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, GeneralSettingsViewModel>
|
||||
public class GetLoggingSettingsHandler : IRequestHandler<GetLoggingSettings, LoggingSettingsViewModel>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetGeneralSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
public GetLoggingSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<GeneralSettingsViewModel> Handle(GetGeneralSettings request, CancellationToken cancellationToken)
|
||||
public async Task<LoggingSettingsViewModel> Handle(GetLoggingSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LogEventLevel> maybeDefaultLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel, cancellationToken);
|
||||
|
||||
Option<LogEventLevel> maybeScanningLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScanning);
|
||||
await _configElementRepository.GetValue<LogEventLevel>(
|
||||
ConfigElementKey.MinimumLogLevelScanning,
|
||||
cancellationToken);
|
||||
|
||||
Option<LogEventLevel> maybeSchedulingLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScheduling);
|
||||
await _configElementRepository.GetValue<LogEventLevel>(
|
||||
ConfigElementKey.MinimumLogLevelScheduling,
|
||||
cancellationToken);
|
||||
|
||||
Option<LogEventLevel> maybeSearchingLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(
|
||||
ConfigElementKey.MinimumLogLevelSearching,
|
||||
cancellationToken);
|
||||
|
||||
Option<LogEventLevel> maybeStreamingLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelStreaming);
|
||||
await _configElementRepository.GetValue<LogEventLevel>(
|
||||
ConfigElementKey.MinimumLogLevelStreaming,
|
||||
cancellationToken);
|
||||
|
||||
Option<LogEventLevel> maybeHttpLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelHttp);
|
||||
await _configElementRepository.GetValue<LogEventLevel>(
|
||||
ConfigElementKey.MinimumLogLevelHttp,
|
||||
cancellationToken);
|
||||
|
||||
return new GeneralSettingsViewModel
|
||||
return new LoggingSettingsViewModel
|
||||
{
|
||||
DefaultMinimumLogLevel = await maybeDefaultLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
ScanningMinimumLogLevel = await maybeScanningLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
SchedulingMinimumLogLevel = await maybeSchedulingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
SearchingMinimumLogLevel = await maybeSearchingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
StreamingMinimumLogLevel = await maybeStreamingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
HttpMinimumLogLevel = await maybeHttpLevel.IfNoneAsync(LogEventLevel.Information)
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetMpegTsScripts : IRequest<List<MpegTsScript>>;
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetMpegTsScriptsHandler(IMpegTsScriptService mpegTsScriptService)
|
||||
: IRequestHandler<GetMpegTsScripts, List<MpegTsScript>>
|
||||
{
|
||||
public async Task<List<MpegTsScript>> Handle(GetMpegTsScripts request, CancellationToken cancellationToken)
|
||||
{
|
||||
await mpegTsScriptService.RefreshScripts();
|
||||
return mpegTsScriptService.GetScripts().OrderBy(x => x.Name).ToList();
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,23 @@ public class GetPlayoutSettingsHandler : IRequestHandler<GetPlayoutSettings, Pla
|
||||
|
||||
public async Task<PlayoutSettingsViewModel> Handle(GetPlayoutSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
|
||||
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.PlayoutDaysToBuild,
|
||||
cancellationToken);
|
||||
|
||||
Option<bool> skipMissingItems =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems);
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems, cancellationToken);
|
||||
|
||||
Option<int> scriptedScheduleTimeoutSeconds =
|
||||
await _configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.PlayoutScriptedScheduleTimeoutSeconds,
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutSettingsViewModel
|
||||
{
|
||||
DaysToBuild = await daysToBuild.IfNoneAsync(2),
|
||||
SkipMissingItems = await skipMissingItems.IfNoneAsync(false)
|
||||
SkipMissingItems = await skipMissingItems.IfNoneAsync(false),
|
||||
ScriptedScheduleTimeoutSeconds = await scriptedScheduleTimeoutSeconds.IfNoneAsync(30)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetUiSettings : IRequest<UiSettingsViewModel>;
|
||||
@@ -0,0 +1,25 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetUiSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetUiSettings, UiSettingsViewModel>
|
||||
{
|
||||
public async Task<UiSettingsViewModel> Handle(GetUiSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<bool> pagesIsDarkMode = await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.PagesIsDarkMode,
|
||||
cancellationToken);
|
||||
|
||||
Option<string> pagesLanguage = await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.PagesLanguage,
|
||||
cancellationToken);
|
||||
|
||||
return new UiSettingsViewModel
|
||||
{
|
||||
IsDarkMode = await pagesIsDarkMode.IfNoneAsync(true),
|
||||
Language = await pagesLanguage.IfNoneAsync("en")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,23 @@ public class GetXmltvSettingsHandler(IConfigElementRepository configElementRepos
|
||||
{
|
||||
public async Task<XmltvSettingsViewModel> Handle(GetXmltvSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> daysToBuild = await configElementRepository.GetValue<int>(ConfigElementKey.XmltvDaysToBuild);
|
||||
Option<int> daysToBuild = await configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.XmltvDaysToBuild,
|
||||
cancellationToken);
|
||||
|
||||
Option<XmltvTimeZone> maybeTimeZone =
|
||||
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone);
|
||||
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken);
|
||||
|
||||
Option<XmltvBlockBehavior> maybeBlockBehavior =
|
||||
await configElementRepository.GetValue<XmltvBlockBehavior>(
|
||||
ConfigElementKey.XmltvBlockBehavior,
|
||||
cancellationToken);
|
||||
|
||||
return new XmltvSettingsViewModel
|
||||
{
|
||||
DaysToBuild = await daysToBuild.IfNoneAsync(2),
|
||||
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local)
|
||||
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local),
|
||||
BlockBehavior = await maybeBlockBehavior.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UiSettingsViewModel
|
||||
{
|
||||
public bool IsDarkMode { get; set; }
|
||||
|
||||
public string Language { get; set; }
|
||||
}
|
||||
7
ErsatzTV.Application/Configuration/XmltvBlockBehavior.cs
Normal file
7
ErsatzTV.Application/Configuration/XmltvBlockBehavior.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public enum XmltvBlockBehavior
|
||||
{
|
||||
SplitTimeEvenly = 0,
|
||||
UseActualTimes = 1
|
||||
}
|
||||
@@ -4,4 +4,5 @@ public class XmltvSettingsViewModel
|
||||
{
|
||||
public int DaysToBuild { get; set; }
|
||||
public XmltvTimeZone TimeZone { get; set; }
|
||||
public XmltvBlockBehavior BlockBehavior { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
|
||||
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyCollectionScannerHandler> logger) : base(
|
||||
dbContextFactory,
|
||||
configElementRepository,
|
||||
runtimeInfo,
|
||||
logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -40,13 +49,16 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeEmbyCollections request)
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyMediaSources
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId)
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId, cancellationToken)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -64,20 +76,40 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.EmbyCollections);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-emby-collections", request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-collections",
|
||||
request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
return BaseError.New("Emby collections are already scanning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
@@ -15,14 +17,17 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyLibraryScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle(
|
||||
@@ -38,9 +43,9 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -53,37 +58,58 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-emby", request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby",
|
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeEmbyLibraryById request)
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
Option<EmbyLibrary> maybeLibrary = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
DateTime minDateTime = maybeLibrary.Match(
|
||||
l => l.LastScan ?? SystemTime.MinValueUtc,
|
||||
() => SystemTime.MaxValueUtc);
|
||||
|
||||
string libraryName = maybeLibrary.Match(l => l.Name, string.Empty);
|
||||
|
||||
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyShowById>,
|
||||
IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyShowScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyShowScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle(
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
parameters => PerformScan(parameters, 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(
|
||||
ScanParameters parameters,
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-show",
|
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
SynchronizeEmbyShowById request) =>
|
||||
true;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
@@ -12,16 +13,19 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
|
||||
private readonly IEmbyApiClient _embyApiClient;
|
||||
private readonly IEmbySecretStore _embySecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public SaveEmbySecretsHandler(
|
||||
IEmbySecretStore embySecretStore,
|
||||
IEmbyApiClient embyApiClient,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IMemoryCache memoryCache,
|
||||
ChannelWriter<IEmbyBackgroundServiceRequest> channel)
|
||||
{
|
||||
_embySecretStore = embySecretStore;
|
||||
_embyApiClient = embyApiClient;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_memoryCache = memoryCache;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
|
||||
parameters.Secrets.Address,
|
||||
parameters.ServerInformation.ServerName,
|
||||
parameters.ServerInformation.OperatingSystem);
|
||||
_memoryCache.Remove(new GetEmbyConnectionParameters());
|
||||
await _channel.WriteAsync(new SynchronizeEmbyMediaSources());
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -2,5 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
: IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -33,18 +33,21 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
SynchronizeEmbyLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(SynchronizeLibraries)
|
||||
Validate(request, cancellationToken)
|
||||
.MapT(p => SynchronizeLibraries(p, cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeEmbyLibraries request) =>
|
||||
MediaSourceMustExist(request)
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(
|
||||
SynchronizeEmbyLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
MediaSourceMustExist(request, cancellationToken)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> MediaSourceMustExist(
|
||||
SynchronizeEmbyLibraries request) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
|
||||
SynchronizeEmbyLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Emby media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
@@ -65,7 +68,9 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
||||
.ToValidation<BaseError>("Emby media source requires an api key");
|
||||
}
|
||||
|
||||
private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters)
|
||||
private async Task<Unit> SynchronizeLibraries(
|
||||
ConnectionParameters connectionParameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, List<EmbyLibrary>> maybeLibraries = await _embyApiClient.GetLibraries(
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
@@ -91,7 +96,8 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
||||
connectionParameters.EmbyMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate);
|
||||
toUpdate,
|
||||
cancellationToken);
|
||||
if (ids.Count != 0)
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
@@ -23,7 +23,7 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
|
||||
SynchronizeEmbyMediaSources request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby();
|
||||
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby(cancellationToken);
|
||||
foreach (EmbyMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyShowById(int EmbyLibraryId, int ShowId, bool DeepScan)
|
||||
: IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest;
|
||||
@@ -23,7 +23,7 @@ public class
|
||||
UpdateEmbyLibraryPreferences request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
var toDisable = request.Preferences.Filter(p => !p.ShouldSyncItems).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisableEmbyLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -15,7 +15,7 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateEmbyPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
Validate(request, cancellationToken)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
@@ -37,13 +37,12 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request) =>
|
||||
EmbyMediaSourceMustExist(request);
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
EmbyMediaSourceMustExist(request, cancellationToken);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
|
||||
UpdateEmbyPathReplacements request) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
|
||||
.Map(
|
||||
v => v.ToValidation<BaseError>(
|
||||
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
|
||||
UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user