Compare commits
455 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -3,11 +3,11 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.1.4",
|
||||
"version": "2025.3.0.2",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
|
||||
[*]
|
||||
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
|
||||
|
||||
[*.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
|
||||
|
||||
# Microsoft .NET properties
|
||||
csharp_new_line_before_members_in_object_initializers=false
|
||||
csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion
|
||||
@@ -58,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
|
||||
@@ -100,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.
|
||||
86
.github/workflows/artifacts.yml
vendored
86
.github/workflows/artifacts.yml
vendored
@@ -25,6 +25,15 @@ 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
|
||||
@@ -48,7 +57,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -72,8 +81,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
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
|
||||
@@ -163,7 +172,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -180,8 +189,8 @@ jobs:
|
||||
|
||||
# Build everything
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
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/"
|
||||
@@ -220,7 +229,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -232,8 +241,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.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 net9.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
|
||||
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
|
||||
@@ -244,28 +253,10 @@ jobs:
|
||||
main/
|
||||
retention-days: 1
|
||||
|
||||
build_rust_windows:
|
||||
name: Build rust for Windows
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Windows Launcher
|
||||
shell: bash
|
||||
run: cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
|
||||
|
||||
- name: Upload Rust Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rust-windows-build
|
||||
path: ErsatzTV-Windows/target/release/ersatztv_windows.exe
|
||||
retention-days: 1
|
||||
|
||||
package_and_upload_windows:
|
||||
name: Package & Upload Windows
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_dotnet_windows, build_rust_windows]
|
||||
runs-on: windows-latest
|
||||
needs: build_dotnet_windows
|
||||
steps:
|
||||
- name: Download dotnet artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -273,11 +264,32 @@ jobs:
|
||||
name: dotnet-windows-build
|
||||
path: dotnet-build
|
||||
|
||||
- name: Download rust artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Azure login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
name: rust-windows-build
|
||||
path: rust-build
|
||||
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
|
||||
@@ -292,18 +304,18 @@ jobs:
|
||||
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"
|
||||
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"
|
||||
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
|
||||
(cd "${release_name}" && 7z a "../${release_name}.zip" .)
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -46,6 +46,10 @@ jobs:
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
|
||||
36
.github/workflows/pr.yml
vendored
36
.github/workflows/pr.yml
vendored
@@ -2,6 +2,31 @@
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
build_and_analyze:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
|
||||
- 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:
|
||||
@@ -11,7 +36,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -27,11 +52,6 @@ jobs:
|
||||
|
||||
- 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:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
@@ -52,7 +72,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -80,7 +100,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -41,6 +41,9 @@ jobs:
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
|
||||
717
CHANGELOG.md
717
CHANGELOG.md
@@ -5,6 +5,693 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [26.3.0] - 2026-02-24
|
||||
### Added
|
||||
- Add log warnings when actual transcoding speed is potentially insufficient to support smooth playback
|
||||
- Log messages will include media item id, channel number and transcoding speed
|
||||
- Add UI language setting to **Settings** > **UI**
|
||||
- A small number of translations have been added for `Português (Brasil)` and `Polski`
|
||||
- Translation contributions are always welcome!
|
||||
- Add `Troubleshoot` button to playout details table to show info that may be helpful in determining the source of a playout item
|
||||
- Classic schedule info includes schedule, schedule item, scheduler, filler, playback order, random seed, collection index
|
||||
- Block schedule info includes block, block item, playback order, random seed, collection index
|
||||
- E.g. items with the same random seed are part of the same shuffle
|
||||
- Add channel setting `Slug Seconds`
|
||||
- This controls how many (optional) seconds of black video and silent audio to insert between *every* playout item
|
||||
- This will drift playback from the wall clock as slugs are not scheduled in the playout, but are inserted dynamically during playback
|
||||
- If this feature turns out to be popular, methods to correct the drift may be investigated
|
||||
- Add `ETV_INSTANCE_ID` environment variable to disambiguate EPG data from multiple ErsatzTV instances
|
||||
- When set, the value will be used in channel identifiers before the final `.ersatztv.org`
|
||||
- Show warning message when selecting audio format `aac (latm)` for general streaming use when it is only intended for DVB-C
|
||||
|
||||
### Changed
|
||||
- Move dark/light mode toggle to **Settings** > **UI**
|
||||
- Use latest (non-deprecated) authorization method with Jellyfin API
|
||||
- Replace direct Discord links with new contact page https://ersatztv.org/contact which also includes other options like Matrix
|
||||
- Lower GOP size and keyframe interval from four seconds to two seconds in accordance with HLS2 draft spec recommendations
|
||||
|
||||
### Fixed
|
||||
- Improve stability of playback orders `Shuffle` and `Shuffle in Order` over time
|
||||
- Fix Trakt list sync
|
||||
- Fix some cases of QSV audio/video desync when *not* seeking by using software decode
|
||||
- This only applies to content that *might* be problematic (using a heuristic)
|
||||
- NVIDIA: force software decode of 10-bit h264 content since hardware decode is unsupported by ffmpeg until version 8
|
||||
- Graphics engine: fix stream seek value used throughout graphics engine
|
||||
- This should fix loading EPG data when used with chapters/mid-roll
|
||||
- This should also fix graphics element visibility when using start_seconds on content with chapters/mid-roll
|
||||
- This bug was caused by stream seek including the playout item in-point (the chapter start time)
|
||||
- Stream seek should only be non-zero when first joining a channel (i.e. in the middle of a playout item or chapter)
|
||||
|
||||
## [26.2.0] - 2026-02-02
|
||||
### Added
|
||||
- Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example:
|
||||
- en-US can match sunday using `day_of_week = 0`
|
||||
- fr-FR can match sunday using `day_of_week = 6`
|
||||
- As a complete example, to match Saturday from 9pm (inclusive) to 11pm (exclusive), based on content start time
|
||||
- `content_condition: day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)`
|
||||
- Add `Pad Mode` to ffmpeg profile. Options are:
|
||||
- `Hardware If Possible` - default/existing behavior when hardware acceleration is properly configured
|
||||
- `Software` - force software padding
|
||||
- This can be used to work around buggy GPU driver behavior where padding is green instead of black
|
||||
- This is most often seen with VAAPI acceleration (radeonsi or i965 drivers)
|
||||
- Add API endpoint to clean artwork cache folder (on demand)
|
||||
- POST `/api/maintenance/clean_artwork`
|
||||
- Add health check to warn about unsupported empty (classic) schedules
|
||||
- Add health check to warn about incompatible ffmpeg due to missing filters
|
||||
- This is directly applicable to homebrew `ffmpeg` on MacOS, which is no longer compatible with ErsatzTV
|
||||
- `ffmpeg@7` or `ffmpeg-full` should be used instead
|
||||
- Add `Marathon Group By` option `Director`
|
||||
- This groups the *first* director on Movies, Episodes, Music Videos and Other Videos
|
||||
- This is supported in classic schedules and sequential schedules
|
||||
- Add FFmpeg Profile options:
|
||||
- `Normalize Audio` (default: true) - normalizes audio streams, or stream copies when disabled
|
||||
- `Normalize Video` (default: true) - normalizes video streams, or stream copies when disabled
|
||||
- `Normalize Colors` (default: true) - normalizes color parameters when enabled
|
||||
- Disabling any of these options may have a significant performance benefit *at the expense of stream stability*
|
||||
- Add chapter `title` to filler expression
|
||||
- This can be used to include or exclude chapters with specific (case-insensitive) titles
|
||||
- E.g. `title == 'here'`, `title != 'not here'`, `title like '%here%'`
|
||||
- Local movie libraries: load fanart from `backdrop` files (created by Jellyfin)
|
||||
|
||||
### Changed
|
||||
- Disable automatic artwork database cleanup
|
||||
- This will be re-enabled at some point in the future (after more testing)
|
||||
- For now, the API should be used to clean as needed
|
||||
- Classic Schedules: make multiple `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the collection
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the collection
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
|
||||
### Fixed
|
||||
- Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`)
|
||||
- Graphics engine:
|
||||
- Respect `z_index` (draw order) on all graphics element types
|
||||
- Fix bug with `z_index` sorting
|
||||
- Restore default UI font that was erroneously removed in v26.1.1
|
||||
- Classic schedules: fix building playouts when `Fill With Group Mode` schedule items also have graphics elements
|
||||
- Use configured searching log level on startup, instead of the default log level of `Information`
|
||||
- MySql: fix searching for shows and seasons in schedule items editor
|
||||
- Fix 500 errors when serving XMLTV due to concurrent file reads and writes
|
||||
- Fix playback of AC3 audio when targeting stereo output and input layout changes mid-stream
|
||||
- Use other video artwork in XMLTV template
|
||||
- Properly update (add or remove) artwork for all local media libraries when files have changed
|
||||
- Sync Plex library name changes
|
||||
- Sync Plex episode title, plot, year, date added, release date, episode number changes
|
||||
- Sync Jellyfin and Emby library name and type changes
|
||||
- Library type (movies, shows) can only be changed when synchronization is *disabled* for the library in ETV
|
||||
- Fix some sequential and scripted playout build failures when using playlists or marathons
|
||||
- Fix erasing playout items and history so all related data is also erased
|
||||
- This includes rerun history, unscheduled gaps, build status
|
||||
- Fix indexing collections when using Elasticsearch backend
|
||||
|
||||
## [26.1.1] - 2026-01-08
|
||||
### Fixed
|
||||
- Use code signing on Windows launcher (`ErsatzTV-Windows.exe`) to avoid antivirus false positive
|
||||
|
||||
### Changed
|
||||
- Optimize database check for orphaned artwork
|
||||
- Include web resources (CSS, JS) locally instead of relying on CDNs
|
||||
|
||||
## [26.1.0] - 2026-01-06
|
||||
### Added
|
||||
- Graphics Engine:
|
||||
- Add `script` graphics element type
|
||||
- Supported in playback troubleshooting and all scheduling types
|
||||
- Supports arbitrary scripts or executables that output graphics to ETV via stdout
|
||||
- Supports EPG and Media Item replacement in entire template
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- All template data will also be passed as JSON to the stdin stream of the command
|
||||
- Template supports:
|
||||
- Script and arguments (`command` and `args`)
|
||||
- Draw order (`z_index`)
|
||||
- Timing (`start_seconds` and `duration_seconds`)
|
||||
- Data format (`format`)
|
||||
- `raw` format means full frames of BGRA data to stdout
|
||||
- `packet` format means ETV graphics packets to stdout
|
||||
- Add framerate template data
|
||||
- `RFrameRate` - the real content framerate (or channel normalized framerate) as reported by ffmpeg, e.g. `30000/1001`
|
||||
- `FrameRate` - the decimal representation of `RFrameRate`, e.g. `29.97002997`
|
||||
- Add `Channel_StartTime` template data
|
||||
- This indicates the time that the transcode session started for the current channel
|
||||
- Add remote stream metadata
|
||||
- Remote stream definitions (yaml files) can now contain `title`, `plot`, `year` and `content_rating` fields
|
||||
- Remote streams can now have thumbnails (same name as yaml file but with image extension)
|
||||
- This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds
|
||||
- Add `Download Media Sample` button to playback troubleshooting
|
||||
- This button will extract up to 30 seconds of the media item and zip it
|
||||
- Add `Target Loudness` (LUFS/LKFS) to ffmpeg profile when loudness normalization is enabled
|
||||
- Default value is `-16`; some sources normalize to a quieter value, e.g. `-24`
|
||||
- Add environment variables to help troubleshoot performance
|
||||
- `ETV_SLOW_DB_MS` - milliseconds threshold for logging slow database queries (at DEBUG level)
|
||||
- e.g. if this is set to `1000`, queries taking longer than 1 second will be logged
|
||||
- `ETV_SLOW_API_MS` - milliseconds threshold for logging slow API calls (at DEBUG level)
|
||||
- This is currently limited to *Jellyfin*
|
||||
- `ETV_JF_PAGE_SIZE` - page size for library scan API calls to Jellyfin; default value is 10
|
||||
- `ETV_JF_ENABLE_STATS` - enables logging timing information related to Jellyfin show library scans
|
||||
- Add `Select All` button to media pages by @Erotemic
|
||||
|
||||
### Fixed
|
||||
- Fix startup on systems unsupported by NvEncSharp
|
||||
- Fix detection of Plex Other Video libraries using `Plex Personal Media` agent
|
||||
- If the library is already detected as a Movies library in ETV, synchronization must be disabled for the library to change it to an Other Videos library
|
||||
- A warning will be logged when this scenario is detected
|
||||
- Graphics Engine:
|
||||
- Optimize graphics engine to generate element frames in parallel and to eliminate redundant frame copies
|
||||
- Match graphics engine framerate with source content (or channel normalized) framerate
|
||||
- Fix loading requested number of epg entries for motion graphics elements
|
||||
- Fix bug with mirror channels where seemingly random content would be played every ~40 seconds
|
||||
- Fix chronological sorting for Other Videos that have release date metadata
|
||||
- Fix playout sorting after using channel number editor
|
||||
- VAAPI: Only include `-sei a53_cc` flags when misc packed headers are supported by the encoder
|
||||
- This should fix playback in some cases, e.g. AMD VAAPI h264 encoder
|
||||
- AMD VAAPI:
|
||||
- work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080
|
||||
- fix green padding when encoding h264 using main profile
|
||||
- Automatically kill playback troubleshooting ffmpeg process if it hasn't completed after two minutes
|
||||
- Fix playback of certain BT.2020 content
|
||||
- Use playlist item count when using a playlist as filler (instead of a fixed count of 1 for each playlist item)
|
||||
- NVIDIA:
|
||||
- Fix stream failure with certain content that should decode in hardware but falls back to software
|
||||
- Fix stream failure with content that changes color metadata mid-stream
|
||||
- Fix stream failure when configured fallback filler collection is empty
|
||||
- Fix high CPU when errors are displayed; errors will now work ahead before throttling to realtime, similar to primary content
|
||||
- Fix startup error caused by duplicate smart collection names (and no longer allow duplicate smart collection names)
|
||||
- Fix erroneous downgrade health check failure with some installations that use MariaDB
|
||||
- Sequential schedules: fix `count` instruction validation to accept integer (constant) or string (expression)
|
||||
- Fix multi-part episode grouping logic so that it does NOT require release date metadata for episodes within a single show
|
||||
- When **Treat Collections As Shows** is enabled (i.e. for crossover episodes) release date metadata is required for proper grouping
|
||||
- Fix *many* cases of duplicate names; enforce case-insensitive unique names at the db schema level
|
||||
- Fix playback when using `ETV_BASE_URL` by @JamesDearlove
|
||||
|
||||
### Changed
|
||||
- No longer round framerate to nearest integer when normalizing framerate
|
||||
- Allow playlists to have no items included in EPG
|
||||
- Change how fallback filler works
|
||||
- Items will no longer loop; instead, a sequence of random items will be selected from the collection
|
||||
- Items may still be cut as needed
|
||||
- Hardware acceleration will now be used
|
||||
- Items can "work ahead" (transcode faster than realtime) when less than 3 minutes in duration
|
||||
- Optimize Jellyfin database fields and indexes
|
||||
- Optimize Jellyfin show library scans by only requesting `People` (actors, directors, writers) when etags don't match
|
||||
- This should significantly speed up periodic library scans, particularly against Jellyfin 10.11.x
|
||||
- Lazy load media item images in UI
|
||||
- Align alternate schedule and template handling (between classic schedules and block schedules)
|
||||
- Both systems now support limiting to a date range
|
||||
- This date range can be repeating (when year is not specified for start or end dates)
|
||||
- This date range can be exact (when year is specified for start and end dates)
|
||||
|
||||
## [25.9.0] - 2025-11-29
|
||||
### Added
|
||||
- Show playout warnings count badge in left menu
|
||||
- Graphics Engine:
|
||||
- Add `MediaItem_Resolution` template data (the current `Resolution` variable is the FFmpeg Profile resolution)
|
||||
- Add `MediaItem_Start` template data (DateTimeOffset)
|
||||
- Add `MediaItem_Stop` template data (DateTimeOffset)
|
||||
- Add `ScaledResolution` template data (the final size of the frame before padding)
|
||||
- Add `place_within_source_content` (true/false) field to image graphics element
|
||||
- Add `name` field to all graphics elements to display in the UI
|
||||
- Classic and block schedules: add collection type `Search Query`
|
||||
- This allows defining search queries directly on schedule items without creating smart collections beforehand
|
||||
- As an example, this can be used to filter or combine existing smart collections
|
||||
- Filter: `smart_collection:"sd movies" AND plot:"christmas"`
|
||||
- Combine: `smart_collection:"old commercials" OR smart_collection:"nick promos"`
|
||||
- Scripted schedules: add `custom_title` to `start_epg_group`
|
||||
- Add MPEG-TS Script system
|
||||
- This allows using something other than ffmpeg (e.g. streamlink) to concatenate segments back together when using MPEG-TS streaming mode
|
||||
- Scripts live in config / scripts / mpegts
|
||||
- Each script gets its own subfolder which contains an `mpegts.yml` definition and corresponding windows (batch) and linux (bash) scripts
|
||||
- The global MPEG-TS script can be configured in **Settings** > **FFmpeg** > **Default MPEG-TS Script**
|
||||
- Add `.avs` AviSynth Script support to all local libraries
|
||||
- `.avs` was added as a valid extension, so they should behave the same any other video file
|
||||
- There are two requirements for AviSynth Scripts to work:
|
||||
- FFmpeg needs to be compiled with AviSynth support (not currently available in Docker)
|
||||
- AviSynth itself needs to be installed
|
||||
- Add `Troubleshoot` button to classic schedule list
|
||||
- This generates JSON representing the entire schedule which can be shared when requested for troubleshooting
|
||||
- Add **Settings** > **FFmpeg** > **Probe For Interlaced Frames**
|
||||
- When enabled, this will probe *local content* for interlaced frames on demand (immediately before playback)
|
||||
- This will be used as a more accurate check for interlaced content
|
||||
- The result will be cached (only probed once and stored) in the database along with all other media item statistics (e.g. duration)
|
||||
- This feature will currently ignore content that is not streamed from disk
|
||||
- Add error/offline background customization
|
||||
- Default error background is now named `_background.png`
|
||||
- Error streams will prioritize using `background.png` if it exists
|
||||
- Replacing this `background.png` file will allow custom error/offline backgrounds
|
||||
- Add `Troubleshoot Playback` buttons on movie and episode detail pages
|
||||
- Add song background and missing album art customization
|
||||
- Default files start with an underscore; custom versions must remove the underscore
|
||||
- Expose arbitrary EPG data to graphics engine via channel guide templates
|
||||
- XML nodes using the `etv:` namespace will be passed to the graphics engine EPG template data
|
||||
- For example, adding `<etv:episode_number_key>{{ episode_number }}</etv:episode_number_key>` to `episode.sbntxt` will also add the `episode_number_key` field to all EPG items in the graphics engine
|
||||
- All values parsed from XMLTV will be available as strings in the graphics engine (not numbers)
|
||||
- All `etv:` nodes will be stripped from the XMLTV data when requested by a client
|
||||
- Add channel troubleshooting button to channels list
|
||||
- This will open the playback troubleshooting tool in "channel" mode
|
||||
- This mode requires entering a date and time, and will play up to 30 seconds of *one item from that channel's playout* starting at the entered date and time
|
||||
- Block schedules: add copy template button to templates table
|
||||
|
||||
### Fixed
|
||||
- Fix HLS Direct playback with Jellyfin 10.11
|
||||
- Fix remote stream scripts (parsing issue with spaces and quotes)
|
||||
- Fix block history being removed when it is still needed for mirror channel
|
||||
- This caused playout build errors like "Unable to locate history for playout item"
|
||||
- Fix crashes due to invalid smart collection searches, e.g. `smart_collection:"this collection does not exist"`
|
||||
- Fix UI crash when editing block playout that has default deco
|
||||
- Fix playback failure when seeking content with certain DTS audio (e.g. DTS-HD MA)
|
||||
- Properly set explicit audio decoder on combined audio and video input file
|
||||
- Fix building sequential schedules across a UTC offset change
|
||||
- Fix block start time calculation across a UTC offset change
|
||||
- Fix classic schedule start time calculation across a UTC offset change
|
||||
- Fix XMLTV generation for channels using on-demand playout mode
|
||||
- Fix some file not found songs missing from trash view
|
||||
- Fix error/offline screen generation
|
||||
- Fix subtitle title sync from Jellyfin libraries
|
||||
- Deep scans will be required to update subtitle titles on existing media items
|
||||
- Fix saving subtitle title changes to the database
|
||||
- This fixes e.g. where stream selection would continue to use the original title
|
||||
- This fix applies to all libraries (local and media server)
|
||||
- Fix (3 year old) bug removing tags from local libraries when they are removed from NFO files (all content types)
|
||||
- New scans will properly remove old tags; NFO files may need to be touched to force updating during a scan
|
||||
- Fix bug where looping motion graphics wouldn't be displayed when seeking into second half of content
|
||||
- Fix `content_total_duration` value in graphics engine opacity expressions
|
||||
- This bug caused some graphics elements to display too early after first joining a channel
|
||||
- Optimize database calls made for search index rebuilds and updates
|
||||
- This should improve performance of library scans
|
||||
- Add toggle to hide/show disabled channels in channel list
|
||||
- Add disabled text color and `(D)` and `(H)` labels for disabled and hidden channels in channel list
|
||||
- Graphics engine: fix subtitle path escaping and font loading
|
||||
- Fix corrupt output (green artifacts) when decoding certain 10-bit content using AMD Polaris GPUs
|
||||
- Work around sequential schedule validation limit (1000/hr by Newtonsoft.Json.Schema library)
|
||||
- Playout builds now use JsonSchema.Net library which has no validation limit
|
||||
- Validation tool in the UI still uses Newtonsoft.Json.Schema (with 1000/hr limit) as the error output is easier to understand
|
||||
- Fix editing scripted and sequential playouts when using MySql
|
||||
- Fix HLS Direct streams remaining open after client disconnect
|
||||
- Always log scanner exit code when it is non-zero
|
||||
|
||||
### Changed
|
||||
- Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them
|
||||
- This mode maintains progress; progress can be reset by editing the playout and clicking `Erase Items and History`
|
||||
- Use smaller batch size for search index updates (100, down from 1000)
|
||||
- This should help newly scanned items appear in the UI more quickly
|
||||
- Replace favicon and logo in background image used for error streams
|
||||
- Block schedules:
|
||||
- Auto scroll day view to block item time when adding and removing block items from template
|
||||
- Allow keyboard selection of
|
||||
- Block groups in block list
|
||||
- Template groups in template list
|
||||
- Block groups and blocks in template editor
|
||||
- Replace template tree view with searchable table (like blocks)
|
||||
- Upgrade to dotnet 10
|
||||
|
||||
## [25.8.0] - 2025-10-26
|
||||
### Added
|
||||
- Graphics engine:
|
||||
- Add template data (like `MediaItem_Title`) for other video files
|
||||
- Add `MediaItem_Path` for movies, episodes, music videos and other videos
|
||||
- Add `get_directory_name` and `get_filename_without_extension` functions for path processing
|
||||
- Add `text_align` property to text graphics elements (values: `left`, `right` and `center`)
|
||||
- Add `MiddleCenter` value to `location` property on all graphics elements
|
||||
- Positive and negative margins can be used to offset from center as desired
|
||||
- Add `line_height` property to text element style definition
|
||||
- This is a multiplier that defaults to 1.0 when unspecified
|
||||
- Add `halo_color`, `halo_width` and `halo_blur` properties to text element style definition
|
||||
- These can be used to "outline" text with the configured color (e.g. `#000000`), width (e.g. `10`) and amount of blur (e.g. `2`)
|
||||
- Add `Block Playout Troubleshooting` tool to help investigate block playout history
|
||||
- Add sequential schedule file and scripted schedule file names to playouts table
|
||||
- Add empty (but already up-to-date) sqlite3 database to greatly speed up initial startup for fresh installs
|
||||
- Add button to copy/clone block from blocks table
|
||||
- Add playback speed to playback troubleshooting output
|
||||
- Speed is relative to realtime (1.0x is realtime)
|
||||
- Speeds < 0.9x will be colored red, between 0.9x and 1.1x colored yellow, and > 1.1x colored green
|
||||
- Add episode thumbnail artwork URL to XMLTV template
|
||||
- By default, poster will be added as image with type "poster" and thumbnail will be added as image with type "still"
|
||||
- Poster will continue to be added as icon by default
|
||||
- Add buttons to edit Jellyfin and Emby connection information in **Media Sources** > **Jellyfin** and **Media Sources** > **Emby**
|
||||
- Add audio format `aac (latm)` for DVB-C compatibility; `aac` uses ADTS by default which is required in most cases
|
||||
- Add deep scan option for external collections (Plex, Jellyfin, Emby)
|
||||
- Jellyfin and Emby collection scans have always been deep scans
|
||||
- Now, by default, they will be quick scans that trust Jellyfin and Emby's etags for detecting changes
|
||||
- If a quick scan misses updating a collection, deep scans can be triggered manually
|
||||
|
||||
### Fixed
|
||||
- Fix NVIDIA startup errors on arm64
|
||||
- Fix remote stream durations in playouts created using block, sequential or scripted schedules
|
||||
- Fix playback troubleshooting selecting a subtitle even with no subtitle stream selected in the UI
|
||||
- Fix intermittent watermark opacity
|
||||
- Improve reliability of live remote streams; they should transcode closer to realtime in most cases
|
||||
- Dramatically improve stream startup time
|
||||
- VAAPI: fix scaling image-based subtitles (e.g. dvdsub)
|
||||
- VAAPI: fix overlaying picture subtitles with scaling behavior crop
|
||||
- Fix HLS Segmenter (fmp4) on Windows
|
||||
- Playback troubleshooting: wait for at least 2 initial segments (up to configured initial segment count) to reduce stalls
|
||||
- Fix Trakt List sync
|
||||
- Fix QSV audio sync
|
||||
- Fix QSV capability detection on Linux using non-drm displays (e.g. wayland)
|
||||
- Fix playlist filtering bug that made HLS Segmenter more likely to fail when streaming for multiple hours
|
||||
- Fix NVIDIA overlaying text subtitles and permanent watermark on 10-bit content
|
||||
- Fix UI error adding deco
|
||||
- Fix UI error editing watermarks and graphics elements on blocks
|
||||
- Fix showing playout build failure details when resetting a playout
|
||||
- Fix scheduling auto-generated trakt list playlists that contain shows
|
||||
- Fix playout builder getting stuck (forever) on block item with an empty collection
|
||||
- Fix HLS Direct playback when using custom stream selector or preferred audio language/title
|
||||
- Fix selecting embedded subtitles (text and picture) with HLS Direct
|
||||
- Fix building scripted schedules across a UTC offset change
|
||||
|
||||
### Changed
|
||||
- Do not use graphics engine for single, permanent watermark
|
||||
- Rename `YAML Validation` tool to `Sequential Schedule Validation`
|
||||
- Greatly reduce debug log spam during playout builds by logging summaries of certain warnings at the end
|
||||
- Remove *experimental* `HLS Segmenter V2` streaming mode; it is not possible to maintain quality output using this mode
|
||||
- Remove *experimental* `HLS Segmenter (fmp4)` streaming mode; this mode only worked properly in a browser, many clients did not like it
|
||||
- Change how scanner process and main process communicate, which should improve reliability of search index updates when scanning
|
||||
|
||||
## [25.7.1] - 2025-10-09
|
||||
### Added
|
||||
- Add search field to filter blocks table
|
||||
- Show full error/exception details in playback troubleshooting logs
|
||||
- Add basic free space validation on startup
|
||||
- ETV will now fail to start with less than 128 MB free space in config or transcode folders
|
||||
- Add downgrade health check to inform users when they are doing something that WILL impact stability
|
||||
|
||||
### Fixed
|
||||
- Do not allow deleting ffmpeg profiles that are used by channels
|
||||
- Do not allow deleting default ffmpeg profile
|
||||
- Allow ffmpeg profiles using VAAPI accel to set h264 video profile
|
||||
- Fix HLS Direct playback, and make it accessible on separate streaming port
|
||||
- Fix playback troubleshooting when using multiple watermarks or multiple graphics elements
|
||||
|
||||
### Changed
|
||||
- Use table instead of tree view on blocks page
|
||||
- Use different release packaging system to workaround false positive from Windows Defender
|
||||
|
||||
## [25.7.0] - 2025-10-03
|
||||
### Added
|
||||
- Add new collection type `Rerun Collection`
|
||||
- This collection type will show up as *two* collection types in classic schedules
|
||||
- `Rerun (First Run)`
|
||||
- `Rerun (Rerun)`
|
||||
- The playback order for each of these collection types can be set on the rerun collection itself
|
||||
- e.g. `Season, Episode` order for first run, `Shuffle` for rerun
|
||||
- When a first run item is added to a playout, it will immediately be made available in the rerun collection
|
||||
- Rerun history is currently scoped to the playout, and only supported in classic schedules
|
||||
- This means resetting the playout will reset the rerun history
|
||||
- Items will still be scheduled from the rerun collection if it is used before the first run collection
|
||||
- Otherwise, the rerun collection would be considered "empty" which prevents the playout build altogether
|
||||
- Add `Rkmpp` hardware acceleration by @peterdey
|
||||
- This is supported using jellyfin-ffmpeg7 on devices like Orange Pi 5 Plus and NanoPi R6S
|
||||
- Block schedules: allow selecting multiple watermarks on block items
|
||||
- Block schedules: allow selecting multiple graphics elements on block items
|
||||
- Add `motion` graphics element type
|
||||
- Supported in playback troubleshooting and all scheduling types
|
||||
- Supports video files with alpha channel (e.g. vp8/vp9 webm, apple prores 4444)
|
||||
- Supports EPG and Media Item replacement in entire template
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- Template supports:
|
||||
- Content (`video_path`)
|
||||
- Placement (`location`, `horizontal_margin_percent`, `vertical_margin_percent`)
|
||||
- Scaling (`scale`, `scale_width_percent`)
|
||||
- Timing (`start_seconds`)
|
||||
- End behavior (`end_behavior`)
|
||||
- `disappear` (default) - disappear after playing once
|
||||
- `loop` - loop forever
|
||||
- `hold` - hold last frame forever, or `hold_seconds`
|
||||
- Draw order (`z_index`)
|
||||
- Add search fields to filter collections, schedules and playouts tables
|
||||
- Add selected row background color to schedules and playouts tables
|
||||
- Graphics engine text element: add `width_percent` and `text_fit` to support wrapping and scaling text
|
||||
- `text_fit: none` or unspecified will keep existing behavior (render text exactly as configured)
|
||||
- `text_fit: wrap` will wrap text to the given `width_percent`
|
||||
- `text_fit: scale` will scale text *smaller* to fit the given `width_percent`
|
||||
- Text that already fits with the configured style will not be adjusted
|
||||
- Block schedules: add **experimental** `Break Content` to decos
|
||||
- Break content is similar to filler from classic schedules
|
||||
- Break content is currently limited to placement `Block Start` (play before anything else in the block)
|
||||
- Future work will add other placement options
|
||||
- Break content is currently limited to playlists (which do *not* pad - they simply play through the playlist one time)
|
||||
- Future work will add other collection options which will pad to the full block duration
|
||||
- Add page to reorder channels (edit channel numbers) using drag and drop
|
||||
- New page is at **Channels** > **Edit Channel Numbers**
|
||||
- Scripted schedules: add setting to configure timeout of scripted playout build
|
||||
- New setting is at **Settings** > **Playout** > **Scripted Schedule Timeout**
|
||||
- Add *experimental* streaming mode `HLS Segmenter (fmp4)`
|
||||
- This mode is required for better compliance with HLS spec, and to support new output codecs
|
||||
- This mode *will replace* `HLS Segmenter` when it has received more testing
|
||||
- Allow HEVC playback in channel preview
|
||||
- This is restricted to compatible browsers
|
||||
- Preview button will be red when preview is disabled due to browser incompatibility
|
||||
- Add AV1 encoding support with NVIDIA, VAAPI and QSV acceleration
|
||||
- This also requires `HLS Segmenter (fmp4)`
|
||||
- Add `Stream Selector` option to playback troubleshooting tool
|
||||
- This can be helpful for validating stream selector behavior with specific content
|
||||
- Manual subtitle selection will be disabled when using a stream selector
|
||||
- Add basic log viewer to playback troubleshooting tool
|
||||
- Streaming log level will be forced to `Debug` during troubleshooting
|
||||
- Streaming log level will be restored to its previous value after troubleshooting completes
|
||||
- Add playout build status to UI
|
||||
- Playouts that fail to build will be highlighted yellow in the playouts table
|
||||
- Clicking on the failed playout will display the warning or error that caused the playout build to fail
|
||||
|
||||
### Fixed
|
||||
- Fix green output when libplacebo tonemapping is used with NVIDIA acceleration and 10-bit output in FFmpeg Profile
|
||||
- Fix playback when invalid video preset has been saved in FFmpegProfile
|
||||
- This can happen when NVIDIA accel falls back to libx264 software encoder for 10-bit h264 output
|
||||
- Fix 10-bit output when using NVIDIA and graphics engine (watermark or other overlays)
|
||||
- Fix playback of Jellyfin content with unknown color range
|
||||
- Block schedules: skip collections (block items) that will never fit in block duration
|
||||
- Block schedules: skip media items that will never fit in block duration
|
||||
- Fix HLS playlist generation for clients that actually care about discontinuities (like hls.js)
|
||||
- This should resolve most playback issues with built-in channel preview
|
||||
- Fix deco dead air fallback selection and duration on mirror channels
|
||||
- Fix fallback filler duration on mirror channels
|
||||
- Fix slow startup caused by check for overlapping playout items
|
||||
- Fix green line in *most* cases when overlaying content using NVIDIA acceleration and H264 output
|
||||
- Fix non-SRT (e.g. SSA/ASS) external subtitle playback from media servers
|
||||
- Fix extracted text subtitle playback from media servers
|
||||
- Fix extracted text subtitles getting into invalid state after media server deep scans
|
||||
- Targeted deep scans will now extract text subtitles for the scanned show
|
||||
- Fix playlist preview
|
||||
- Use NVIDIA NvEnc API to detect encoder capability instead of heuristic based on GPU model/architecture
|
||||
- Use NVIDIA Cuvid API to detect decoder capability instead of heuristic based on GPU model/architecture
|
||||
- Fix filler expression not being respected when using a playlist as filler
|
||||
- Use "repeat count" metadata from animated GIFs in graphics engine (i.e. watermarks)
|
||||
- GIFs flagged to loop forever will loop forever
|
||||
- GIFs with a specific loop count will loop the specified number of times and then hold the final frame
|
||||
- Note that looping is relative to the start of the content, so this works best with permanent watermarks
|
||||
- Fix some more hls.js warnings by adding codec information to multi-variant playlists
|
||||
- Fix hardware decode of h264 constrained baseline content using VAAPI accel
|
||||
- Custom stream selector: ignore embedded text subtitles that have not been extracted
|
||||
- Fix cropping Jellyfin and Emby content that is smaller than the crop resolution
|
||||
- Sync movies with non-file media sources (e.g. http/nfs) from Emby movie libraries by @jasonarends
|
||||
|
||||
### Changed
|
||||
- Filler presets: use separate text fields for `hours`, `minutes` and `seconds` duration
|
||||
- Use autocomplete fields for collection searching in deco editor
|
||||
- This greatly improves the editor performance
|
||||
|
||||
## [25.6.0] - 2025-09-14
|
||||
### Added
|
||||
- Classic schedules: allow selecting multiple graphics elements on schedule items
|
||||
- Block schedules: allow selecting multiple graphics elements on decos
|
||||
- Add channel `Playout Source` setting
|
||||
- `Generated`: default/existing behavior where channel must have its own playout
|
||||
- `Mirror`: channel will play content from the specified `Mirror Source Channel`'s playout
|
||||
- This allows the exact same content on different channels with different channel settings
|
||||
- `Playout Offset` can be used to offset the times of scheduled playout items from the mirror source channel
|
||||
- e.g. -2 hours will cause the mirror channel to play content 2 hours before the mirror source channel
|
||||
- Add support for `.aif`, `.aifc`, `.aiff` song files
|
||||
- Classic schedules: add playback order `Marathon`
|
||||
- This can be used with collections and smart collections
|
||||
- Items from the collection will be grouped by the `Marathon Group By` setting: `Artist`, `Album`, `Season` or `Show`
|
||||
- The order of groups can optionally be shuffled
|
||||
- The order of items in each group can optionally be shuffled (otherwise `Season, Episode` or `Chronological` as appropriate)
|
||||
- A batch size can be set to limit the number of items to schedule from each group at a time
|
||||
- Empty or zero batch size means play all items from each group before advancing
|
||||
- Any other value means play the specified number of items before advancing to the next group
|
||||
- Log API requests when `Request Logging Minimum Log Level` is set to `Debug`
|
||||
- Add `Count` setting to each playlist item
|
||||
- Previously, when `Play All` was unchecked, this was implicitly 1
|
||||
- Now, the playlist can play a specific number of items from the collection before moving to the next playlist item
|
||||
- Classic schedules: add `Shuffle Playlist Items` setting to shuffle the order of playlist items
|
||||
- Shuffling happens initially (on playout reset), and after all items from the *entire playlist* have been played
|
||||
- Add playout detail row coloring by @peterdey
|
||||
- Filler has unique row colors
|
||||
- Unscheduled gaps are now displayed and have a unique row color
|
||||
- Process entire graphics element YAML files using scriban
|
||||
- This allows things like different images based on `MediaItem_ContentRating` (movie) or `MediaItem_ShowContentRating` (episode)
|
||||
- Playlists: add playback order `Shuffle In Order` for collections and smart collections
|
||||
|
||||
### Fixed
|
||||
- Fix transcoding content with bt709/pc color metadata
|
||||
- Fix scripted schedule validation (file exists) when creating or editing playout
|
||||
- Fix adding single episode, movie, season, show to empty playlists
|
||||
- Fix startup with MySql as non-superuser
|
||||
- `local_infile=ON` is required when using MySQL (for bulk inserts when building playouts)
|
||||
- ETV will set this automatically when it has permission
|
||||
- When ETV does not have permission, startup will fail with logged instructions on how to configure MySql
|
||||
- Fix scaling anamorphic content in locales that don't use period as a decimal separator (e.g. `,`)
|
||||
- Block schedules: fix playout build crash when empty collection uses random playback order
|
||||
- Fix watermarks and graphics elements on primary content split by mid-roll filler
|
||||
- Fix watermarks and graphics elements when `Scaling Behavior` is `Crop`
|
||||
- Fix hardware acceleration health check message on mobile
|
||||
- Fix deco selection logic
|
||||
- Fix inefficient database migration that would cause database initialization to get stuck
|
||||
- Classic schedules: fix scheduling behavior when a flood item is before a flexible fixed start item
|
||||
- Sometimes the flood item wouldn't schedule anything
|
||||
- Fix troubleshooting certain text graphics elements by generating fake EPG data
|
||||
|
||||
### Changed
|
||||
- **BREAKING CHANGE**: change how `Scripted Schedule` system works
|
||||
- No longer uses embedded python (IronPython); instead uses HTTP API
|
||||
- OpenAPI Description has been added at `/openapi/scripted-schedule.json`
|
||||
- This allows scripted scheduling from *many* languages
|
||||
- The scripted schedule file must now be directly executable (though a wrapper can be used to load a venv)
|
||||
- The scripted schedule file will be passed the following arguments (in order):
|
||||
- The API host (e.g. `http://localhost:8409`)
|
||||
- The build id (a UUID string that is required on all API calls)
|
||||
- The playout build mode (e.g. `reset` or `continue`, normally only used for specific logic when resetting a playout)
|
||||
- Custom arguments can be included in the `Scripted Schedule` field in the playout editor
|
||||
- Custom arguments will be passed *after* required arguments
|
||||
- For example, a `Scripted Schedule` of `/home/jason/schedule.sh "party central" 23` will be executed like
|
||||
- `/home/jason/schedule.sh http://localhost:8409 00000000-0000...0000 reset "party central" 23`
|
||||
- This enables wrapper script re-use across multiple scripted schedules
|
||||
- API reference is available at `/docs`
|
||||
- Docker images contain pre-generated python api client and entrypoint script
|
||||
- Entrypoint is at `/app/scripted-schedules/entrypoint.py`
|
||||
- Scripts folder should be mounted to `/app/scripted-schedules/scripts`
|
||||
- Playouts should be created with scripted schedule `/app/scripted-schedules/entrypoint.py script-name` (no trailing `.py`)
|
||||
- Automatically ignore Specials/Season 0 when using `Season, Episode` playback order
|
||||
|
||||
## [25.5.0] - 2025-09-01
|
||||
### Added
|
||||
- Add *experimental* graphics engine
|
||||
- All watermarks will use new graphics engine
|
||||
- Add `Opacity Expression` watermark mode
|
||||
- This allows specifying an expression that returns an opacity between 0.0 and 1.0
|
||||
- The expression can use:
|
||||
- `content_seconds` - the total number of seconds the frame is into the content
|
||||
- `content_total_seconds` - the total number of seconds in the content
|
||||
- `channel_seconds` - the total number of seconds the frame is from when the channel started/activated
|
||||
- `time_of_day_seconds` - the total number of seconds the frame is since midnight
|
||||
- The expression can also use functions:
|
||||
- `LinearFadeDuration(time, start, fadeSeconds, peakSeconds)`
|
||||
- `LinearFadePoints(time, start, peakStart, peakEnd, end)`
|
||||
- Add `Z-Index` to watermark editor
|
||||
- The graphics engine will order by z-index when overlaying watermarks
|
||||
- Add *experimental* `Graphics Element` template system
|
||||
- Graphics elements are defined in YAML files inside ETV config folder / templates / graphics-elements subfolder
|
||||
- Add `text` graphics element type
|
||||
- Supported in playback troubleshooting and YAML playouts
|
||||
- Displays multi-line text in a specified font, color, location, z-index
|
||||
- Supports constant opacity and opacity expression
|
||||
- Supports EPG and Media Item variable replacement
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- Add `image` graphics element type
|
||||
- Supported in playback troubleshooting and YAML playouts
|
||||
- Displays an image, similar to a watermark
|
||||
- Supports constant opacity and opacity expression
|
||||
- Add `subtitle` graphics element type
|
||||
- Supported in playback troubleshooting and YAML playouts
|
||||
- Supports SRT and SSA/ASS subtitle formats
|
||||
- Supports EPG and Media Item variable replacement
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- YAML playout: add `graphics_on` and `graphics_off` instructions to control graphics elements
|
||||
- `graphics_on` requires the name of a graphics element template, e.g. `text/cool_element.yml`
|
||||
- The `variables` property can be used to dynamically replace text from the template
|
||||
- `graphics_off` will turn off a specific element, or all elements if none are specified
|
||||
- Add `Seek Seconds` to playback troubleshooting to support capturing timing-related issues
|
||||
- Custom stream selector: add `content_condition` to allow channel and time-of-day based decisions
|
||||
- `content_condition` expression can use
|
||||
- `channel_number`
|
||||
- `channel_name`
|
||||
- `time_of_day_seconds` - the start time for the current item, represented in seconds since midnight
|
||||
- Add support for external chapter files next to video files
|
||||
- Currently supports Matroska Chapter XML format
|
||||
- Chapter files have .xml or .chapters extension
|
||||
- Add targeted (single-show) library scanning
|
||||
- Supports quick and deep scans
|
||||
- Can be triggered from the `Scan` button on show pages
|
||||
- Can be triggered by API call to `/api/libraries/{library-id}/scan-show`
|
||||
- Add XMLTV setting `XMLTV Block Behavior` to control how block schedules appear in the EPG
|
||||
- `Split Time Evenly` - default (existing) behavior; block time is split among all items that are visible in the EPG
|
||||
- `Use Actual Times` - actual times are used for all items that are visible in the EPG
|
||||
- This will introduce EPG gaps when filler is used, or when items are hidden from the EPG
|
||||
- Add *experimental* `Scripted Schedule` playout system
|
||||
- This system uses python scripts to support the highest degree of customization
|
||||
- The goal is to expose methods equivalent to all sequential schedule (YAML) instructions
|
||||
- YAML and Scripted schedules: add `offline_tail` and `stop_before_end` to `pad_to_next` instruction
|
||||
- Both parameters default to `true`
|
||||
|
||||
### Fix
|
||||
- Fix database operations that were slowing down playout builds
|
||||
- YAML playouts in particular should build significantly faster
|
||||
- Fix channel playout mode `On Demand` for Block and YAML schedules
|
||||
- Fix QSV transitions when remote streaming from a media server
|
||||
- Fix green output when padding with VAAPI accel and i965 driver
|
||||
- Fix watermark custom image validation
|
||||
- Fix playback when using any watermarks that were saved with invalid state (no image)
|
||||
- Fix overlapping block playout items caused by `Stop scheduling block items` value `After Duration End`
|
||||
- Existing overlapping items will not be removed, but no new overlapping items will be created
|
||||
- Until these existing items age out, there will be warnings logged after each playout build/extension
|
||||
- Fix playback of anamorphic content from Jellyfin
|
||||
- This fix requires a manual deep scan of any affected Jellyfin library
|
||||
- Fix bug where multiple Plex servers would mix their episodes
|
||||
- Fix incorrect media item counts after removing paths from local libraries
|
||||
- Fix song playback in playback troubleshooting
|
||||
- Fix seeking into extracted text subtitles
|
||||
- Fix error when changing default (lowest priority) alternate schedule
|
||||
- Fix remote library editing, tv shows, artists with MySql/MariaDB
|
||||
- Classic schedules: fix alternate schedule transitions (some edge cases would cause days to be skipped completely)
|
||||
- Classic schedules: always start new alternate schedules with the first schedule item
|
||||
- Classic Schedules: log offline gaps longer than 1 hour due to strict fixed start times
|
||||
- Fix `HLS Segmenter V2` streaming mode with AMF acceleration
|
||||
- Fix `HLS Segmenter V2` streaming mode with VideoToolbox acceleration
|
||||
- Fix startup process for database and search index initialization
|
||||
- Redirect all pages to home page when initializing to prevent errors
|
||||
- Clear stale sqlite migration lock on startup to prevent getting stuck on database initialization
|
||||
- Fix display of long season placeholder text (when season posters are unavailable)
|
||||
|
||||
### Changed
|
||||
- Rename some schedule and playout terms for clarity
|
||||
- Schedules are used to build playouts and are what actually differs
|
||||
- The playout is the end result, and is the same no matter what schedule kind is used
|
||||
- Supported schedule kinds:
|
||||
- `Classic Schedules`
|
||||
- `Block Schedules`
|
||||
- `Sequential Schedules` (formerly `YAML Schedules` or `YAML Playouts`)
|
||||
- `Scripted Schedules`
|
||||
- `JSON (dizqueTV) Schedules` (formerly `External JSON Playouts`)
|
||||
- Allow multiple watermarks in playback troubleshooting
|
||||
- Classic schedules: allow selecting multiple watermarks on schedule items
|
||||
- Block schedules: allow selecting multiple watermarks on decos
|
||||
- Block schedules: change available watermark modes on decos. For reference, the levels from highest to lowest with block schedules are `Global` > `Channel` > `Playout Default Deco` > `Template Deco`.
|
||||
- `Inherit` - Use watermarks configured at a higher level
|
||||
- `Disable` - Disable watermarks at this level and above
|
||||
- `Replace` - Replace all watermarks configured at a higher level with those on this deco
|
||||
- This was renamed from `Override`
|
||||
- `Merge` - Merge all watermarks configured at a higher level with those on this deco
|
||||
- YAML playout: `watermark` instruction changes:
|
||||
- When value is `true`, will add named watermark to list of active watermarks
|
||||
- When value is `false` and `name` is specified, will remove named watermark from list of active watermarks
|
||||
- When value is `false` and `name` is not specified, will clear all active watermarks
|
||||
- Use consistent UI sorting and validation, and fix renaming errors for
|
||||
- Block groups, blocks
|
||||
- Template groups, templates
|
||||
- Deco groups, decos
|
||||
- Deco template groups, deco templates
|
||||
|
||||
## [25.4.0] - 2025-08-05
|
||||
### Added
|
||||
- Add `Troubleshoot Playback` to overflow menu on all media cards
|
||||
@@ -142,13 +829,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `random` will start at a random point in the content
|
||||
- `2` (similar to before this change) will skip the first two items in the content
|
||||
- YAML playout: make `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the content
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the content
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- YAML playout: add `disable_watermarks` property to all content instructions
|
||||
- This property defaults to `false` (meaning watermarks are allowed by default)
|
||||
- Setting to `true` will prevent watermarks from ever appearing over the content
|
||||
@@ -1950,7 +2637,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Allow `Shuffle In Order` with Collections and Smart Collections
|
||||
- Episodes will be grouped by show, and music videos will be grouped by artist
|
||||
- All movies will be a single group (multi-collections are probably better if `Shuffle In Order` is desired for movies)
|
||||
- All groups will be be ordered chronologically (custom ordering is only supported in multi-collections)
|
||||
- All groups will be ordered chronologically (custom ordering is only supported in multi-collections)
|
||||
|
||||
### Fixed
|
||||
- Generate XMLTV that validates successfully
|
||||
@@ -2505,7 +3192,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.4.0...HEAD
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.3.0...HEAD
|
||||
[26.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...v26.3.0
|
||||
[26.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...v26.2.0
|
||||
[26.1.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
|
||||
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...v26.1.0
|
||||
[25.9.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...v25.9.0
|
||||
[25.8.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...v25.8.0
|
||||
[25.7.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.0...v25.7.1
|
||||
[25.7.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.6.0...v25.7.0
|
||||
[25.6.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.5.0...v25.6.0
|
||||
[25.5.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.4.0...v25.5.0
|
||||
[25.4.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.3.1...v25.4.0
|
||||
[25.3.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.3.0...v25.3.1
|
||||
[25.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.2.0...v25.3.0
|
||||
@@ -2635,4 +3332,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
[0.0.5-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.4-prealpha...v0.0.5-prealpha
|
||||
[0.0.4-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.3-prealpha...v0.0.4-prealpha
|
||||
[0.0.3-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.1-prealpha...v0.0.3-prealpha
|
||||
[0.0.1-prealpha]: https://github.com/ErsatzTV/ErsatzTV/releases/tag/v0.0.1-prealpha
|
||||
[0.0.1-prealpha]: https://github.com/ErsatzTV/ErsatzTV/releases/tag/v0.0.1-prealpha
|
||||
|
||||
@@ -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,115 +0,0 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use special_folder::SpecialFolder;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Child;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use windows::Win32::System::Console;
|
||||
use {std::sync::mpsc, tray_item::TrayItem};
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
enum Message {
|
||||
Exit,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut tray = TrayItem::new("ErsatzTV", "ersatztv-icon").unwrap();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
tray.add_menu_item("Launch Web UI", || {
|
||||
let ui_port = env::var("ETV_UI_PORT")
|
||||
.ok()
|
||||
.and_then(|val| val.parse::<u16>().ok())
|
||||
.unwrap_or(8409);
|
||||
|
||||
let _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("/C")
|
||||
.arg("start")
|
||||
.arg(format!("http://localhost:{}", ui_port))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.add_menu_item("Show Logs", || {
|
||||
let path = SpecialFolder::LocalApplicationData
|
||||
.get()
|
||||
.unwrap()
|
||||
.join("ersatztv")
|
||||
.join("logs");
|
||||
match path.to_str() {
|
||||
None => {}
|
||||
Some(folder) => {
|
||||
fs::create_dir_all(folder).unwrap();
|
||||
let _ = Command::new("explorer.exe")
|
||||
.arg(folder)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.inner_mut().add_separator().unwrap();
|
||||
|
||||
tray.add_menu_item("Exit", move || {
|
||||
tx.send(Message::Exit).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path = process_path::get_executable_path();
|
||||
let mut child: Option<Child> = None;
|
||||
match path {
|
||||
None => {}
|
||||
Some(path) => {
|
||||
let etv = path.parent().unwrap().join("ErsatzTV.exe");
|
||||
if etv.exists() {
|
||||
match etv.to_str() {
|
||||
None => {}
|
||||
Some(etv) => {
|
||||
child = Some(
|
||||
Command::new(etv)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(Message::Exit) => {
|
||||
match child {
|
||||
None => {}
|
||||
Some(mut child) => {
|
||||
unsafe {
|
||||
if Console::AttachConsole(child.id()) == true
|
||||
{
|
||||
Console::GenerateConsoleCtrlEvent(Console::CTRL_C_EVENT, 0);
|
||||
}
|
||||
}
|
||||
child.wait().unwrap();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Submodule ErsatzTV-macOS updated: d4dd985fd6...8dbe1e22f2
@@ -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));
|
||||
|
||||
@@ -21,7 +21,7 @@ public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
|
||||
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"));
|
||||
|
||||
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);
|
||||
@@ -11,12 +11,16 @@ public record ChannelViewModel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
|
||||
@@ -10,12 +10,16 @@ public record CreateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
|
||||
@@ -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,17 +36,19 @@ 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))
|
||||
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) =>
|
||||
fillerPresetId,
|
||||
_) =>
|
||||
{
|
||||
var artwork = new List<Artwork>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
|
||||
@@ -73,10 +76,15 @@ public class CreateChannelHandler(
|
||||
{
|
||||
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,
|
||||
@@ -94,6 +102,16 @@ public class CreateChannelHandler(
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg
|
||||
};
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
channel.PlayoutMode = ChannelPlayoutMode.Continuous;
|
||||
}
|
||||
else
|
||||
{
|
||||
channel.MirrorSourceChannelId = null;
|
||||
channel.PlayoutOffset = null;
|
||||
}
|
||||
|
||||
foreach (int id in watermarkId)
|
||||
{
|
||||
channel.WatermarkId = id;
|
||||
@@ -113,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"),
|
||||
() =>
|
||||
@@ -132,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)
|
||||
@@ -142,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)
|
||||
{
|
||||
@@ -150,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))
|
||||
@@ -159,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)
|
||||
{
|
||||
@@ -168,11 +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."));
|
||||
}
|
||||
|
||||
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,216 +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 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)
|
||||
try
|
||||
{
|
||||
File.Delete(targetFile);
|
||||
return;
|
||||
}
|
||||
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
|
||||
|
||||
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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
var minifier = new XmlMinifier(
|
||||
new XmlMinificationSettings
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
|
||||
private async Task WritePlayoutXml(
|
||||
@@ -266,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
|
||||
@@ -339,6 +408,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
|
||||
@@ -355,60 +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)
|
||||
.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 = xmltvTimeZone switch
|
||||
switch (xmltvBlockBehavior)
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
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()
|
||||
};
|
||||
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
DateTimeOffset actualFinish = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
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);
|
||||
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,
|
||||
minifier,
|
||||
xml);
|
||||
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;
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,6 +545,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
Template musicVideoTemplate,
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
Template remoteStreamTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml)
|
||||
{
|
||||
@@ -486,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
|
||||
};
|
||||
|
||||
@@ -573,6 +704,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
showMetadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(showMetadata);
|
||||
string thumbnailPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
@@ -593,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),
|
||||
@@ -749,6 +883,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
metadata.Genres ??= [];
|
||||
metadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
@@ -763,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
|
||||
@@ -778,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",
|
||||
@@ -803,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",
|
||||
@@ -826,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",
|
||||
@@ -849,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",
|
||||
@@ -872,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",
|
||||
@@ -893,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;
|
||||
@@ -948,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]"
|
||||
};
|
||||
}
|
||||
@@ -996,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,4 +1,5 @@
|
||||
using System.Data.Common;
|
||||
using System.IO.Abstractions;
|
||||
using System.Net;
|
||||
using System.Xml;
|
||||
using Dapper;
|
||||
@@ -19,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;
|
||||
@@ -26,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;
|
||||
}
|
||||
@@ -44,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",
|
||||
@@ -118,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) and C.IsEnabled = 1 and C.ShowInEPG = 1
|
||||
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
|
||||
|
||||
|
||||
@@ -11,12 +11,16 @@ public record UpdateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
@@ -23,17 +24,35 @@ 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;
|
||||
@@ -86,7 +105,7 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
await dbContext.Entry(c)
|
||||
.Collection(channel => channel.Artwork)
|
||||
.LoadAsync();
|
||||
.LoadAsync(cancellationToken);
|
||||
|
||||
foreach (Artwork artwork in c.Artwork.Where(x => x.ArtworkKind is ArtworkKind.Logo).ToList())
|
||||
{
|
||||
@@ -95,54 +114,125 @@ public class UpdateChannelHandler(
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ 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,
|
||||
@@ -14,16 +14,20 @@ internal static class Mapper
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfileId,
|
||||
channel.SlugSeconds,
|
||||
GetLogo(channel),
|
||||
channel.StreamSelectorMode,
|
||||
channel.StreamSelector,
|
||||
channel.PreferredAudioLanguageCode,
|
||||
channel.PreferredAudioTitle,
|
||||
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,
|
||||
@@ -46,8 +50,14 @@ 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 ArtworkContentTypeModel GetLogo(Channel channel)
|
||||
{
|
||||
@@ -72,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@ public class GetChannelByIdHandler(IChannelRepository channelRepository)
|
||||
{
|
||||
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
|
||||
channelRepository.GetChannel(request.Id)
|
||||
.MapT(ProjectToViewModel);
|
||||
.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,29 +1,22 @@
|
||||
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)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
public async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
FFmpegProfile ffmpegProfile = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
@@ -34,11 +27,11 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
|
||||
if (!ffmpegProfile.NormalizeFramerate)
|
||||
{
|
||||
return Option<int>.None;
|
||||
return Option<FrameRate>.None;
|
||||
}
|
||||
|
||||
// TODO: expand to check everything in collection rather than what's scheduled?
|
||||
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
@@ -68,51 +61,53 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
|
||||
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
|
||||
.Flatten()
|
||||
.Map(mv => mv.RFrameRate)
|
||||
.Map(mv => new FrameRate(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)
|
||||
var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList();
|
||||
if (validFrameRates.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
|
||||
FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate);
|
||||
logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
24,
|
||||
result);
|
||||
|
||||
return 24;
|
||||
distinct.Map(fr => fr.RFrameRate),
|
||||
result.RFrameRate);
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
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,
|
||||
result);
|
||||
return result;
|
||||
distinct.Map(fr => fr.RFrameRate),
|
||||
FrameRate.DefaultFrameRate.RFrameRate,
|
||||
minFrameRate.RFrameRate);
|
||||
|
||||
return FrameRate.DefaultFrameRate;
|
||||
}
|
||||
|
||||
if (distinct.Count != 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
distinct[0].RFrameRate);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Unexpected error checking frame rates on channel {ChannelNumber}",
|
||||
request.ChannelNumber);
|
||||
@@ -120,22 +115,4 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
|
||||
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,5 +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;
|
||||
@@ -9,27 +11,18 @@ 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)
|
||||
@@ -37,13 +30,13 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
.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))
|
||||
@@ -51,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
|
||||
@@ -60,27 +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;
|
||||
}
|
||||
|
||||
if (hiddenChannelNumbers.Contains(Path.GetFileName(fileName)))
|
||||
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||
try
|
||||
{
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
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,7 +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()
|
||||
_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,15 +4,11 @@ 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,
|
||||
@@ -27,7 +23,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
var result = new List<Channel>();
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
if (channel.IsEnabled == false)
|
||||
if (!channel.IsEnabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -38,10 +34,6 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
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 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);
|
||||
}
|
||||
|
||||
@@ -18,13 +18,14 @@ public class UpdateLibraryRefreshIntervalHandler :
|
||||
Validate(request)
|
||||
.MapT(_ => _configElementRepository.Upsert(
|
||||
ConfigElementKey.LibraryRefreshInterval,
|
||||
request.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();
|
||||
}
|
||||
|
||||
@@ -19,36 +19,36 @@ public class UpdateLoggingSettingsHandler : IRequestHandler<UpdateLoggingSetting
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateLoggingSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings);
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings, cancellationToken);
|
||||
|
||||
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings)
|
||||
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = loggingSettings.DefaultMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelScanning,
|
||||
loggingSettings.ScanningMinimumLogLevel);
|
||||
loggingSettings.ScanningMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = loggingSettings.ScanningMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelScheduling,
|
||||
loggingSettings.SchedulingMinimumLogLevel);
|
||||
loggingSettings.SchedulingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = loggingSettings.SchedulingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelSearching,
|
||||
loggingSettings.SearchingMinimumLogLevel);
|
||||
loggingSettings.SearchingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.SearchingLevelSwitch.MinimumLevel = loggingSettings.SearchingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelStreaming,
|
||||
loggingSettings.StreamingMinimumLogLevel);
|
||||
loggingSettings.StreamingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = loggingSettings.StreamingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelHttp,
|
||||
loggingSettings.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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -14,22 +14,32 @@ public class GetLoggingSettingsHandler : IRequestHandler<GetLoggingSettings, Log
|
||||
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);
|
||||
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 LoggingSettingsViewModel
|
||||
{
|
||||
|
||||
@@ -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,12 +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)
|
||||
UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@ public class GetAllEmbyMediaSourcesHandler : IRequestHandler<GetAllEmbyMediaSour
|
||||
public Task<List<EmbyMediaSourceViewModel>> Handle(
|
||||
GetAllEmbyMediaSources request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetAllEmby().Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
_mediaSourceRepository.GetAllEmby(cancellationToken).Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
}
|
||||
|
||||
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
|
||||
await Validate()
|
||||
await Validate(cancellationToken)
|
||||
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address, cp.ApiKey))
|
||||
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
|
||||
|
||||
@@ -47,13 +47,14 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
error => error);
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
||||
EmbyMediaSourceMustExist()
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(CancellationToken cancellationToken) =>
|
||||
EmbyMediaSourceMustExist(cancellationToken)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
|
||||
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
|
||||
private Task<Validation<BaseError, EmbyMediaSource>>
|
||||
EmbyMediaSourceMustExist(CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetAllEmby(cancellationToken).Map(list => list.HeadOrNone())
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
"Emby media source does not exist."));
|
||||
|
||||
|
||||
@@ -14,5 +14,5 @@ public class
|
||||
public Task<Option<EmbyMediaSourceViewModel>> Handle(
|
||||
GetEmbyMediaSourceById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId).MapT(ProjectToViewModel);
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken).MapT(ProjectToViewModel);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="4.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.9.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.7" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -30,4 +27,4 @@
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=emby_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpeg_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=hdhr_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=hdhr_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=health_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.FFmpeg;
|
||||
|
||||
public record RefreshFFmpegCapabilities : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,45 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpeg;
|
||||
|
||||
public class RefreshFFmpegCapabilitiesHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
|
||||
ILocalStatisticsProvider localStatisticsProvider)
|
||||
: IRequestHandler<RefreshFFmpegCapabilities>
|
||||
{
|
||||
public async Task Handle(RefreshFFmpegCapabilities request, CancellationToken cancellationToken)
|
||||
{
|
||||
hardwareCapabilitiesFactory.ClearCache();
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<string> maybeFFmpegPath = await dbContext.ConfigElements
|
||||
.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
|
||||
.FilterT(File.Exists);
|
||||
|
||||
foreach (string ffmpegPath in maybeFFmpegPath)
|
||||
{
|
||||
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
|
||||
|
||||
Option<string> maybeFFprobePath = await dbContext.ConfigElements
|
||||
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
|
||||
.FilterT(File.Exists);
|
||||
|
||||
foreach (string ffprobePath in maybeFFprobePath)
|
||||
{
|
||||
Either<BaseError, MediaVersion> result = await localStatisticsProvider.GetStatistics(
|
||||
ffprobePath,
|
||||
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "test.avs"));
|
||||
|
||||
hardwareCapabilitiesFactory.SetAviSynthInstalled(result.IsRight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
public record CreateFFmpegProfile(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -14,6 +16,7 @@ public record CreateFFmpegProfile(
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -26,7 +29,9 @@ public record CreateFFmpegProfile(
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
NormalizeLoudnessMode NormalizeLoudnessMode,
|
||||
double? TargetLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
|
||||
@@ -24,7 +24,7 @@ public class CreateFFmpegProfileHandler :
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||
}
|
||||
|
||||
@@ -40,39 +40,67 @@ public class CreateFFmpegProfileHandler :
|
||||
|
||||
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateFFmpegProfile request) =>
|
||||
(ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(dbContext, request))
|
||||
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
|
||||
CreateFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(ValidateName(request), ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((name, threadCount, resolutionId) =>
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = threadCount,
|
||||
HardwareAcceleration = request.HardwareAcceleration,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
VideoFormat = request.VideoFormat,
|
||||
VideoProfile = request.VideoProfile,
|
||||
VideoPreset = request.VideoPreset,
|
||||
AllowBFrames = request.AllowBFrames,
|
||||
var hwAccel = request.NormalizeVideo
|
||||
? request.HardwareAcceleration
|
||||
: HardwareAccelerationKind.None;
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: request.BitDepth,
|
||||
return new FFmpegProfile
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = threadCount,
|
||||
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
TonemapAlgorithm = request.TonemapAlgorithm,
|
||||
AudioFormat = request.AudioFormat,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
NormalizeAudio = request.NormalizeAudio,
|
||||
NormalizeVideo = request.NormalizeVideo,
|
||||
|
||||
HardwareAcceleration = hwAccel,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
|
||||
// only allow customization with VAAPI accel
|
||||
PadMode = hwAccel switch
|
||||
{
|
||||
HardwareAccelerationKind.None => FilterMode.Software,
|
||||
HardwareAccelerationKind.Vaapi => request.PadMode,
|
||||
_ => FilterMode.HardwareIfPossible
|
||||
},
|
||||
|
||||
VideoFormat = request.NormalizeVideo ? request.VideoFormat : FFmpegProfileVideoFormat.Copy,
|
||||
VideoProfile = request.VideoProfile,
|
||||
VideoPreset = request.VideoPreset,
|
||||
AllowBFrames = request.AllowBFrames,
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: request.BitDepth,
|
||||
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
TonemapAlgorithm = request.TonemapAlgorithm,
|
||||
AudioFormat = request.NormalizeAudio ? request.AudioFormat : FFmpegProfileAudioFormat.Copy,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
|
||||
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
|
||||
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
|
||||
? request.TargetLoudness
|
||||
: null,
|
||||
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
NormalizeColors = request.NormalizeColors,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
};
|
||||
});
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
|
||||
@@ -84,9 +112,10 @@ public class CreateFFmpegProfileHandler :
|
||||
|
||||
private static Task<Validation<BaseError, int>> ResolutionMustExist(
|
||||
TvContext dbContext,
|
||||
CreateFFmpegProfile createFFmpegProfile) =>
|
||||
CreateFFmpegProfile createFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Resolutions
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId)
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId, cancellationToken)
|
||||
.MapT(r => r.Id)
|
||||
.Map(o => o.ToValidation<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist"));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -7,23 +8,18 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
|
||||
public class DeleteFFmpegProfileHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
|
||||
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
}
|
||||
|
||||
@@ -31,14 +27,58 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
|
||||
{
|
||||
dbContext.FFmpegProfiles.Remove(ffmpegProfile);
|
||||
await dbContext.SaveChangesAsync();
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
searchTargets.SearchTargetsChanged();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken),
|
||||
await FFmpegProfileMustNotBeDefault(request, cancellationToken),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((_, _, ffmpegProfile) => ffmpegProfile);
|
||||
|
||||
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request) =>
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.FFmpegProfileId == request.FFmpegProfileId)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Cannot delete FFmpeg Profile that is used by {count} {(count > 1 ? "channels" : "channel")}");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeDefault(
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> defaultFFmpegProfileId =
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
|
||||
|
||||
if (defaultFFmpegProfileId.Any(id => id == request.FFmpegProfileId))
|
||||
{
|
||||
return BaseError.New("Cannot delete default FFmpeg Profile");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class NewFFmpegProfileHandler : IRequestHandler<NewFFmpegProfile, FFmpegP
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
int defaultResolutionId = await dbContext.ConfigElements
|
||||
.GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId)
|
||||
.GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId, cancellationToken)
|
||||
.IfNoneAsync(0);
|
||||
|
||||
List<Resolution> allResolutions = await dbContext.Resolutions
|
||||
|
||||
@@ -8,6 +8,8 @@ public record UpdateFFmpegProfile(
|
||||
int FFmpegProfileId,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -15,6 +17,7 @@ public record UpdateFFmpegProfile(
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -27,7 +30,9 @@ public record UpdateFFmpegProfile(
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
NormalizeLoudnessMode NormalizeLoudnessMode,
|
||||
double? TargetLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
|
||||
@@ -1,48 +1,49 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg.Preset;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class
|
||||
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
|
||||
public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
: IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
|
||||
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
|
||||
UpdateFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
FFmpegProfile p,
|
||||
UpdateFFmpegProfile update)
|
||||
UpdateFFmpegProfile update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hwAccel = update.NormalizeVideo
|
||||
? update.HardwareAcceleration
|
||||
: HardwareAccelerationKind.None;
|
||||
|
||||
p.Name = update.Name;
|
||||
p.ThreadCount = update.ThreadCount;
|
||||
p.HardwareAcceleration = update.HardwareAcceleration;
|
||||
p.NormalizeAudio = update.NormalizeAudio;
|
||||
p.NormalizeVideo = update.NormalizeVideo;
|
||||
p.HardwareAcceleration = hwAccel;
|
||||
p.VaapiDisplay = update.VaapiDisplay;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.ScalingBehavior = update.ScalingBehavior;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
p.PadMode = update.PadMode;
|
||||
p.VideoFormat = update.NormalizeVideo ? update.VideoFormat : FFmpegProfileVideoFormat.Copy;
|
||||
p.VideoProfile = update.VideoProfile;
|
||||
p.VideoPreset = update.VideoPreset;
|
||||
p.AllowBFrames = update.AllowBFrames;
|
||||
@@ -52,50 +53,106 @@ public class
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: update.BitDepth;
|
||||
|
||||
if (p.HardwareAcceleration is not (HardwareAccelerationKind.Nvenc or HardwareAccelerationKind.Vaapi
|
||||
or HardwareAccelerationKind.Qsv) &&
|
||||
p.VideoFormat is FFmpegProfileVideoFormat.Av1)
|
||||
{
|
||||
p.VideoFormat = FFmpegProfileVideoFormat.Hevc;
|
||||
}
|
||||
|
||||
// only allow customization with VAAPI accel
|
||||
if (p.HardwareAcceleration is HardwareAccelerationKind.None)
|
||||
{
|
||||
p.PadMode = FilterMode.Software;
|
||||
}
|
||||
else if (p.HardwareAcceleration is not HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
p.PadMode = FilterMode.HardwareIfPossible;
|
||||
}
|
||||
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.TonemapAlgorithm = update.TonemapAlgorithm;
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
p.AudioFormat = update.NormalizeAudio ? update.AudioFormat : FFmpegProfileAudioFormat.Copy;
|
||||
p.AudioBitrate = update.AudioBitrate;
|
||||
p.AudioBufferSize = update.AudioBufferSize;
|
||||
|
||||
p.NormalizeLoudnessMode = update.NormalizeLoudnessMode;
|
||||
p.TargetLoudness = update.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
|
||||
? update.TargetLoudness
|
||||
: null;
|
||||
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeFramerate = update.NormalizeFramerate;
|
||||
p.NormalizeColors = update.NormalizeColors;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
// don't save invalid preset
|
||||
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
|
||||
p.HardwareAcceleration,
|
||||
p.VideoFormat,
|
||||
p.BitDepth);
|
||||
|
||||
if (!presets.Contains(p.VideoPreset))
|
||||
{
|
||||
p.VideoPreset = VideoPreset.Unset;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
return new UpdateFFmpegProfileResult(p.Id);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile request) =>
|
||||
(await FFmpegProfileMustExist(dbContext, request), ValidateName(request), ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request))
|
||||
UpdateFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateName(dbContext, request),
|
||||
ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
UpdateFFmpegProfile updateFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
updateFFmpegProfile.NotEmpty(x => x.Name)
|
||||
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name));
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile)
|
||||
{
|
||||
if (updateFFmpegProfile.Name.Length > 50)
|
||||
{
|
||||
return BaseError.New($"FFmpeg profile name \"{updateFFmpegProfile.Name}\" is invalid");
|
||||
}
|
||||
|
||||
Option<FFmpegProfile> maybeExisting = await dbContext.FFmpegProfiles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(ff =>
|
||||
ff.Id != updateFFmpegProfile.FFmpegProfileId && ff.Name == updateFFmpegProfile.Name)
|
||||
.Map(Optional);
|
||||
|
||||
return maybeExisting.IsSome
|
||||
? BaseError.New($"An ffmpeg profile named \"{updateFFmpegProfile.Name}\" already exists in the database")
|
||||
: Success<BaseError, string>(updateFFmpegProfile.Name);
|
||||
}
|
||||
|
||||
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
|
||||
|
||||
private static Task<Validation<BaseError, int>> ResolutionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
UpdateFFmpegProfile updateFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Resolutions
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId)
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken)
|
||||
.MapT(r => r.Id)
|
||||
.Map(o => o.ToValidation<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist"));
|
||||
}
|
||||
|
||||
@@ -1,35 +1,26 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.FFmpeg;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
|
||||
public class UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
IFileSystem fileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
: IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateFFmpegSettings request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(_ => ApplyUpdate(request))
|
||||
.MapT(_ => ApplyUpdate(request, cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
|
||||
@@ -44,7 +35,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
if (!fileSystem.File.Exists(path))
|
||||
{
|
||||
return BaseError.New($"{name} path does not exist");
|
||||
}
|
||||
@@ -69,82 +60,104 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
: BaseError.New($"Unable to verify {name} version");
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
|
||||
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath);
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath, cancellationToken);
|
||||
await configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath, cancellationToken);
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegDefaultProfileId,
|
||||
request.Settings.DefaultFFmpegProfileId.ToString(CultureInfo.InvariantCulture));
|
||||
await _configElementRepository.Upsert(
|
||||
request.Settings.DefaultFFmpegProfileId.ToString(CultureInfo.InvariantCulture),
|
||||
cancellationToken);
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegSaveReports,
|
||||
request.Settings.SaveReports.ToString());
|
||||
await _configElementRepository.Upsert(
|
||||
request.Settings.SaveReports.ToString(),
|
||||
cancellationToken);
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegHlsDirectOutputFormat,
|
||||
request.Settings.HlsDirectOutputFormat);
|
||||
request.Settings.HlsDirectOutputFormat,
|
||||
cancellationToken);
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegDefaultMpegTsScript,
|
||||
request.Settings.DefaultMpegTsScript,
|
||||
cancellationToken);
|
||||
|
||||
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
|
||||
{
|
||||
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegPreferredLanguageCode,
|
||||
request.Settings.PreferredAudioLanguageCode);
|
||||
request.Settings.PreferredAudioLanguageCode,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
|
||||
request.Settings.UseEmbeddedSubtitles);
|
||||
request.Settings.UseEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
|
||||
// do not extract when subtitles are not used
|
||||
if (request.Settings.UseEmbeddedSubtitles == false)
|
||||
if (!request.Settings.UseEmbeddedSubtitles)
|
||||
{
|
||||
request.Settings.ExtractEmbeddedSubtitles = false;
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
|
||||
request.Settings.ExtractEmbeddedSubtitles);
|
||||
request.Settings.ExtractEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
|
||||
// queue extracting all embedded subtitles
|
||||
if (request.Settings.ExtractEmbeddedSubtitles)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None));
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
|
||||
}
|
||||
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegProbeForInterlacedFrames,
|
||||
request.Settings.ProbeForInterlacedFrames,
|
||||
cancellationToken);
|
||||
|
||||
if (request.Settings.GlobalWatermarkId is not null)
|
||||
{
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegGlobalWatermarkId,
|
||||
request.Settings.GlobalWatermarkId.Value);
|
||||
request.Settings.GlobalWatermarkId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||
await configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
}
|
||||
|
||||
if (request.Settings.GlobalFallbackFillerId is not null)
|
||||
{
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegGlobalFallbackFillerId,
|
||||
request.Settings.GlobalFallbackFillerId.Value);
|
||||
request.Settings.GlobalFallbackFillerId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId);
|
||||
await configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegSegmenterTimeout,
|
||||
request.Settings.HlsSegmenterIdleTimeout);
|
||||
request.Settings.HlsSegmenterIdleTimeout,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegWorkAheadSegmenters,
|
||||
request.Settings.WorkAheadSegmenterLimit);
|
||||
request.Settings.WorkAheadSegmenterLimit,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegInitialSegmentCount,
|
||||
request.Settings.InitialSegmentCount);
|
||||
request.Settings.InitialSegmentCount,
|
||||
cancellationToken);
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshFFmpegCapabilities(), cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ public record FFmpegProfileViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -15,6 +17,7 @@ public record FFmpegProfileViewModel(
|
||||
int? QsvExtraHardwareFrames,
|
||||
ResolutionViewModel Resolution,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -27,7 +30,9 @@ public record FFmpegProfileViewModel(
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
NormalizeLoudnessMode NormalizeLoudnessMode,
|
||||
double? TargetLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user