Compare commits
512 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e58bb9af21 | |||
| aa6d8eae4c | |||
| f1e97b94a7 | |||
| 5034941a79 | |||
|
|
0d301df5e8 | ||
|
|
d26ae336cb | ||
|
|
875069b927 | ||
|
|
fd86cb55f9 | ||
|
|
0c30c47ba9 | ||
|
|
08cbf59527 | ||
|
|
a91de68a5c | ||
|
|
3e3bfbd5f5 | ||
|
|
31b07305ef | ||
|
|
49adcf7c37 | ||
|
|
c0b8ff1a06 | ||
|
|
c6d538e012 | ||
|
|
3dbde17f68 | ||
|
|
794d209941 | ||
|
|
7b9197d48d | ||
|
|
2ad6547349 | ||
|
|
4fa11b6943 | ||
|
|
440d9f708e | ||
|
|
4d469ec8fd | ||
|
|
a77a2d56ae | ||
|
|
240a329526 | ||
|
|
45e7d61676 | ||
|
|
93811876e0 | ||
|
|
607d9b0662 | ||
|
|
f47134d2d0 | ||
|
|
ae13db981d | ||
|
|
b7cc8499a3 | ||
|
|
36147b9e9c | ||
|
|
bf8c821012 | ||
|
|
a0f5d8d5d5 | ||
|
|
f1072b70c7 | ||
|
|
e10b28bc0b | ||
|
|
cd2bb0f2e0 | ||
|
|
e80f687612 | ||
|
|
317ca1967c | ||
|
|
b86f45844c | ||
|
|
353f029452 | ||
|
|
1754e7d5fb | ||
|
|
f96be8f99f | ||
|
|
08ceb53b2b | ||
|
|
3d81f760ee | ||
|
|
4ce87feac1 | ||
|
|
f217ba185b | ||
|
|
e925bd6913 | ||
|
|
3f4c9e063b | ||
|
|
7f361d1ea9 | ||
|
|
35d24ffea6 | ||
|
|
a2d023ee69 | ||
|
|
36f44f14bb | ||
|
|
ccb917d0df | ||
|
|
343a4619a6 | ||
|
|
e167c9318c | ||
|
|
de230f92db | ||
|
|
974020a98f | ||
|
|
da957c9377 | ||
|
|
b72d150775 | ||
|
|
b0b7bd17b3 | ||
|
|
1f2f04f3bd | ||
|
|
5bc90bb245 | ||
|
|
f73a32ec13 | ||
|
|
748ed1cf71 | ||
|
|
f2deaa6f7a | ||
|
|
3698fa5b7d | ||
|
|
dc92cb4ac3 | ||
|
|
69410b1a9b | ||
|
|
4aee03e066 | ||
|
|
e16d6c67f1 | ||
|
|
5d8877975d | ||
|
|
367305d960 | ||
|
|
aa08ad5765 | ||
|
|
40c6c504fe | ||
|
|
933b6530e4 | ||
|
|
885330f8c5 | ||
|
|
effb96a2c2 | ||
|
|
cc521326d9 | ||
|
|
5c42609527 | ||
|
|
a96ef328a5 | ||
|
|
89bb3759de | ||
|
|
12f2583c96 | ||
|
|
7c82ecdfff | ||
|
|
38343e3ea2 | ||
|
|
bcd2ea7db3 | ||
|
|
80f6e468eb | ||
|
|
474e647d6d | ||
|
|
daff1c6533 | ||
|
|
14d2dd0c3a | ||
|
|
c606319030 | ||
|
|
1b72b8491c | ||
|
|
9fea25a77d | ||
|
|
74b049b6e3 | ||
|
|
b2caf8ee8d | ||
|
|
b582b4cbf7 | ||
|
|
0af81ad839 | ||
|
|
2f0cd1eb6c | ||
|
|
e4f1a93db0 | ||
|
|
6562d616fb | ||
|
|
d8122edad6 | ||
|
|
99b8c56a31 | ||
|
|
09858df654 | ||
|
|
038286c92b | ||
|
|
8575ab5c32 | ||
|
|
8b768a2990 | ||
|
|
f9e4c4d386 | ||
|
|
a1f9b86fc1 | ||
|
|
5dc20ebd1b | ||
|
|
d30e8b4102 | ||
|
|
c14f373f23 | ||
|
|
a90fe26d50 | ||
|
|
7a263ddaed | ||
|
|
3e0a9aae1e | ||
|
|
72dc401829 | ||
|
|
85e25ca6ea | ||
|
|
9c23b03758 | ||
|
|
e12888ebee | ||
|
|
468ff087d4 | ||
|
|
54606c76f9 | ||
|
|
6bd49ffcec | ||
|
|
c524bc0d7d | ||
|
|
42bcadf936 | ||
|
|
1f31beab5b | ||
|
|
b45c22092d | ||
|
|
7bd8cefe2e | ||
|
|
f101d0b366 | ||
|
|
73aabdabda | ||
|
|
bcea96d53a | ||
|
|
d7952e4cfa | ||
|
|
758399e339 | ||
|
|
6c635a4be9 | ||
|
|
9d637cdd54 | ||
|
|
cc287ffc6e | ||
|
|
371659c5c5 | ||
|
|
7afb1866ad | ||
|
|
7bc1dd63fe | ||
|
|
076b8a7188 | ||
|
|
ec0d8ea6ac | ||
|
|
e40d192aea | ||
|
|
bd7fd8984c | ||
|
|
2682912f5a | ||
|
|
505e135482 | ||
|
|
fdf1e70e0d | ||
|
|
5c51710e2f | ||
|
|
3cb84c2491 | ||
|
|
21f4439aa4 | ||
|
|
d88e721d2f | ||
|
|
b6d509b9cd | ||
|
|
6603500132 | ||
|
|
48b1aa3e64 | ||
|
|
42b35f7aae | ||
|
|
8b18f2a304 | ||
|
|
1e0bba0dc6 | ||
|
|
3984bc7dbe | ||
|
|
e0977fa65b | ||
|
|
d9c668c7f6 | ||
|
|
dcea6d474f | ||
|
|
ba37c6dabe | ||
|
|
d652372f78 | ||
|
|
e2d8dee8cd | ||
|
|
d93c404607 | ||
|
|
bc400de94c | ||
|
|
b9a73226a8 | ||
|
|
d0505cd5c5 | ||
|
|
d9cdbc72de | ||
|
|
2b0079fedc | ||
|
|
9d2cff53c5 | ||
|
|
ac361b3165 | ||
|
|
dd9317e3e8 | ||
|
|
7530c592ff | ||
|
|
132466b3d3 | ||
|
|
d709cc9f21 | ||
|
|
5083e748ed | ||
|
|
053b3cd1d7 | ||
|
|
c3c7ff2669 | ||
|
|
e6824cf251 | ||
|
|
d87561d140 | ||
|
|
f79fa9a50a | ||
|
|
629b3d7d9f | ||
|
|
453737a521 | ||
|
|
dd38ba19ea | ||
|
|
8e2a15296f | ||
|
|
d2cbfcb79a | ||
|
|
89133255d3 | ||
|
|
c6245bae0c | ||
|
|
2912e71c10 | ||
|
|
9e54d42e5f | ||
|
|
63f342e6a7 | ||
|
|
5a7c59d602 | ||
|
|
4822ba5486 | ||
|
|
b24617fe7c | ||
|
|
5045a411b1 | ||
|
|
425fb34317 | ||
|
|
e5ef9be09c | ||
|
|
e9338b534b | ||
|
|
191e694545 | ||
|
|
727a978689 | ||
|
|
7a133d46da | ||
|
|
b1fbf651a2 | ||
|
|
0dbdcc3674 | ||
|
|
9eb7bbf0e6 | ||
|
|
e851a295a6 | ||
|
|
3b254735e6 | ||
|
|
1f8834c280 | ||
|
|
545db4db9b | ||
|
|
e590298b93 | ||
|
|
a47510fef3 | ||
|
|
e089b12c2b | ||
|
|
82e0fcaec8 | ||
|
|
f7c699248c | ||
|
|
2d53063ce9 | ||
|
|
626048f9c3 | ||
|
|
2ef2b0299a | ||
|
|
fcce53a3df | ||
|
|
d4353f6d42 | ||
|
|
64ea413b6f | ||
|
|
d14ebf3522 | ||
|
|
889904e70d | ||
|
|
35e7922836 | ||
|
|
ffe15629cb | ||
|
|
ba5a027525 | ||
|
|
a33ac4a048 | ||
|
|
7ae028e2e9 | ||
|
|
6404dee646 | ||
|
|
940d26419c | ||
|
|
9bae8e73bf | ||
|
|
f41f4b19d4 | ||
|
|
917acf9683 | ||
|
|
da4687ac0f | ||
|
|
d1af6599f0 | ||
|
|
4e43817f8e | ||
|
|
ebcd9a35a7 | ||
|
|
ea5956a268 | ||
|
|
65ff1f5502 | ||
|
|
5ef8b04119 | ||
|
|
99837e808a | ||
|
|
7c2083d3f2 | ||
|
|
b851a7daba | ||
|
|
48e7c85f7b | ||
|
|
bf4182f115 | ||
|
|
fb9ca8953e | ||
|
|
48310e044b | ||
|
|
144b3fe80b | ||
|
|
4a754c4e6a | ||
|
|
7059669023 | ||
|
|
c03f81a465 | ||
|
|
e3d07050bf | ||
|
|
5a88bfc310 | ||
|
|
dd92a65742 | ||
|
|
07ffa1642b | ||
|
|
6fc602323f | ||
|
|
d5fd8e7be6 | ||
|
|
dba5485300 | ||
|
|
6847a133ca | ||
|
|
fd60c120ae | ||
|
|
371d1d89fb | ||
|
|
ec6bc797f4 | ||
|
|
4c57167864 | ||
|
|
9016523757 | ||
|
|
6a38c91d54 | ||
|
|
9ec4d0a85c | ||
|
|
4cb98242ba | ||
|
|
0e2084838a | ||
|
|
f3f900b4ca | ||
|
|
c39858b2d8 | ||
|
|
0363609923 | ||
|
|
a02aa37957 | ||
|
|
86a7563da5 | ||
|
|
cd4715a32e | ||
|
|
fc04118bf9 | ||
|
|
dd5fd1ef8f | ||
|
|
404f898b2f | ||
|
|
3e8ac9914c | ||
|
|
a0788532a0 | ||
|
|
b3daad7c3b | ||
|
|
598de5d5d6 | ||
|
|
5c174eabdb | ||
|
|
18905a79dc | ||
|
|
077fed6cac | ||
|
|
fac0f36d35 | ||
|
|
287adc34b5 | ||
|
|
6ff153f01d | ||
|
|
e3af0f0b69 | ||
|
|
b46de50801 | ||
|
|
77163e6746 | ||
|
|
1763c897eb | ||
|
|
ac45d6acd4 | ||
|
|
8b4b7cf16a | ||
|
|
b820b798cb | ||
|
|
dc92a96bd9 | ||
|
|
18523dce64 | ||
|
|
ffb50a9404 | ||
|
|
7462039301 | ||
|
|
c71269058e | ||
|
|
9ec220c122 | ||
|
|
fc97a2da3c | ||
|
|
ddc1120904 | ||
|
|
e70e4fb826 | ||
|
|
b790b5944c | ||
|
|
0ca1859802 | ||
|
|
07a160fcc6 | ||
|
|
788a1ecdc4 | ||
|
|
004da8b7aa | ||
|
|
c8679144c5 | ||
|
|
a389c1bbbe | ||
|
|
ecacf3960f | ||
|
|
aa5ba5a78e | ||
|
|
9e111a103e | ||
|
|
8bc3457de0 | ||
|
|
febabcff6f | ||
|
|
f26e48c063 | ||
|
|
6465c416ff | ||
|
|
a0e0bb8753 | ||
|
|
b9451a6585 | ||
|
|
0c49f4799f | ||
|
|
ea008776b1 | ||
|
|
9aa7c44388 | ||
|
|
d855e4f20d | ||
|
|
9da655e210 | ||
|
|
03b9db7835 | ||
|
|
245165c9d9 | ||
|
|
fe5dd80f70 | ||
|
|
8155e2e441 | ||
|
|
307b9dadd2 | ||
|
|
5379a893f7 | ||
|
|
64bb1b0d61 | ||
|
|
89968f722a | ||
|
|
bc721755f5 | ||
|
|
9182a8ad18 | ||
|
|
c5265943f5 | ||
|
|
07a55da76e | ||
|
|
3722bc8c9c | ||
|
|
87bc779d48 | ||
|
|
f124554fba | ||
|
|
17c7774603 | ||
|
|
4e065fe922 | ||
|
|
2cca3123aa | ||
|
|
dabc67976a | ||
|
|
bd6954121f | ||
|
|
a2fd23a131 | ||
|
|
388623f82e | ||
|
|
6b275f8a13 | ||
|
|
0d69dd58a4 | ||
|
|
79e8fa0877 | ||
|
|
044c8b7ad3 | ||
|
|
e8b51e8442 | ||
|
|
d8e8abb691 | ||
|
|
e9093d0c48 | ||
|
|
cb78b21d1c | ||
|
|
906ec44a6e | ||
|
|
e96ac0202b | ||
|
|
5e7da19e5e | ||
|
|
e25b669cc4 | ||
|
|
d80c6737a9 | ||
|
|
ef5de99f9c | ||
|
|
e047812a68 | ||
|
|
3da5144a0d | ||
|
|
31355ab887 | ||
|
|
63bac272cd | ||
|
|
8d5a208129 | ||
|
|
487d99dc69 | ||
|
|
40ab7c2cff | ||
|
|
a6de96c2ea | ||
|
|
0d82e0234b | ||
|
|
1e9e41b808 | ||
|
|
57e9b4d264 | ||
|
|
73b8d68a09 | ||
|
|
03921e1ff7 | ||
|
|
70aae67873 | ||
|
|
67cb931a47 | ||
|
|
704c1ec535 | ||
|
|
06332c8360 | ||
|
|
03b4419f67 | ||
|
|
7ac93c6aad | ||
|
|
6ca72baa00 | ||
|
|
6b953ab5ca | ||
|
|
272f528f7a | ||
|
|
07c1156a63 | ||
|
|
eadacc7f8c | ||
|
|
380070731a | ||
|
|
7720e6ba39 | ||
|
|
8a1cf72209 | ||
|
|
b9759c983c | ||
|
|
9462156148 | ||
|
|
1c07df5bc3 | ||
|
|
a6198892f0 | ||
|
|
02a91c4e14 | ||
|
|
b62a76d339 | ||
|
|
d9f2f51aee | ||
|
|
8e77330781 | ||
|
|
66c28e9b5f | ||
|
|
51ec84c94a | ||
|
|
a072e4357e | ||
|
|
605c57bef3 | ||
|
|
4e2310d008 | ||
|
|
61a99c250a | ||
|
|
bbddd50f00 | ||
|
|
53f281ce32 | ||
|
|
e06ee54070 | ||
|
|
af23c6d541 | ||
|
|
988ed8db04 | ||
|
|
31c18162e1 | ||
|
|
0318e71745 | ||
|
|
1e7f9a5709 | ||
|
|
330195d5e3 | ||
|
|
5d081ceeff | ||
|
|
6d32dac51b | ||
|
|
4f02bedf69 | ||
|
|
d71443ef60 | ||
|
|
d5608ac75f | ||
|
|
a6b01cbe28 | ||
|
|
d0af507bef | ||
|
|
f626954eb7 | ||
|
|
62e140ec98 | ||
|
|
93bb7a0531 | ||
|
|
f31a48c429 | ||
|
|
0841bc400b | ||
|
|
8cc0d30c0e | ||
|
|
4b18ee6b66 | ||
|
|
558e2ce333 | ||
|
|
c9e6e601c2 | ||
|
|
d28d0a9805 | ||
|
|
ac75a67709 | ||
|
|
5e463758da | ||
|
|
2cb0d12701 | ||
|
|
44ec0f8a0f | ||
|
|
b149f7f2a3 | ||
|
|
771bfba01c | ||
|
|
820c2a5ccc | ||
|
|
91c4e8f575 | ||
|
|
a04adf45c0 | ||
|
|
8cbc3b083a | ||
|
|
1cac210765 | ||
|
|
6f9952924b | ||
|
|
1bf5b9567b | ||
|
|
a9f2037648 | ||
|
|
03c5b7e664 | ||
|
|
0e7ec6e3b9 | ||
|
|
3f247288d3 | ||
|
|
df0801f2c6 | ||
|
|
908125f8a9 | ||
|
|
942cf9e225 | ||
|
|
075f3fcac7 | ||
|
|
f4eadae8ff | ||
|
|
2dc5bf58a7 | ||
|
|
76a589b538 | ||
|
|
9f3db05c17 | ||
|
|
7ca2763109 | ||
|
|
14539d00d4 | ||
|
|
bd09f3dfdc | ||
|
|
0c22eefad2 | ||
|
|
2f06e5b6f7 | ||
|
|
f9db92d5e6 | ||
|
|
f2b6f5b919 | ||
|
|
c7fcaf8886 | ||
|
|
5a5c049835 | ||
|
|
a28f40e14b | ||
|
|
a2fc99229e | ||
|
|
036b6e63c7 | ||
|
|
fd7c3fc25a | ||
|
|
93dca6e0e0 | ||
|
|
e34368bf07 | ||
|
|
a4b485f562 | ||
|
|
6159b6a5b2 | ||
|
|
11100a788b | ||
|
|
b40ac9ef52 | ||
|
|
c055e59723 | ||
|
|
b52159e8db | ||
|
|
a728c5e31e | ||
|
|
61ce1bad08 | ||
|
|
ab2b926de0 | ||
|
|
3b955255ce | ||
|
|
16dd2c2d81 | ||
|
|
48f93b8af8 | ||
|
|
8b12ee459a | ||
|
|
b3d0b44e77 | ||
|
|
163fd0c1f3 | ||
|
|
b6ec16c6a7 | ||
|
|
aa3bd3b750 | ||
|
|
f04b7ead09 | ||
|
|
8921273900 | ||
|
|
0489741123 | ||
|
|
c3e882085b | ||
|
|
3ab9112c15 | ||
|
|
33b789db67 | ||
|
|
ed5206b855 | ||
|
|
baf7aa20d1 | ||
|
|
7bd0de99e1 | ||
|
|
96093c8cc8 | ||
|
|
8430a3048c | ||
|
|
06d9e59a7a | ||
|
|
9c434079d5 | ||
|
|
12c88a006d | ||
|
|
f0ca358c2b | ||
|
|
093abf7ad8 | ||
|
|
f768093df7 | ||
|
|
3830db60bf | ||
|
|
5984b38ce0 | ||
|
|
e0175fc4e5 | ||
|
|
4f104cff5b | ||
|
|
a2f678fe8e | ||
|
|
b3ac0c68a8 | ||
|
|
605d8a98ab | ||
|
|
00f40c2568 | ||
|
|
74733a8026 | ||
|
|
1df9104854 | ||
|
|
6c6ccfa94b | ||
|
|
e9d494c24e | ||
|
|
deff33c76c | ||
|
|
b5d1839d55 | ||
|
|
ab0f431c85 |
167
.claude/skills/ersatztv/SKILL.md
Normal file
167
.claude/skills/ersatztv/SKILL.md
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: ersatztv
|
||||
description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when managing custom TV channels.
|
||||
---
|
||||
|
||||
# ErsatzTV Channel Management
|
||||
|
||||
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
|
||||
Web UI: internal only (`http://localhost:8409` via SSH)
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
|
||||
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
|
||||
|
||||
## Architecture
|
||||
|
||||
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
|
||||
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
|
||||
- **POST endpoints**: library scan, playout reset, show scan
|
||||
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
|
||||
|
||||
## REST API
|
||||
|
||||
```bash
|
||||
# Via docker exec
|
||||
docker exec ersatztv curl -s http://localhost:8409/api/ENDPOINT
|
||||
```
|
||||
|
||||
### Read Endpoints (GET)
|
||||
```
|
||||
/api/channels # List channels
|
||||
/api/collections # List collections
|
||||
/api/schedules # List schedules
|
||||
/api/playouts # List playouts
|
||||
/api/shows # List shows
|
||||
/api/movies # List movies
|
||||
/api/artists # List artists
|
||||
/api/search # Search items
|
||||
/api/ffmpeg/profiles # FFmpeg profiles
|
||||
/api/watermarks # Watermarks
|
||||
/iptv/channels.m3u # M3U playlist (for Jellyfin)
|
||||
/iptv/xmltv.xml # XMLTV guide data
|
||||
```
|
||||
|
||||
### Mutation Endpoints (POST)
|
||||
```bash
|
||||
# Library scan
|
||||
POST /api/libraries/{id}/scan
|
||||
|
||||
# Scan single show
|
||||
POST /api/libraries/{id}/scan-show \
|
||||
-H "Content-Type: application/json" -d '{"ShowTitle":"Name","DeepScan":false}'
|
||||
|
||||
# Reset channel playout (rebuilds schedule)
|
||||
POST /api/channels/{channelNumber}/playout/reset
|
||||
```
|
||||
|
||||
## SQLite DB Operations
|
||||
|
||||
```bash
|
||||
# Read queries (safe while running, WAL mode)
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
|
||||
# Write queries — stop container first
|
||||
docker stop ersatztv
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
docker start ersatztv
|
||||
```
|
||||
|
||||
### Key Queries
|
||||
```sql
|
||||
-- List channels
|
||||
SELECT Id, Number, Name FROM Channel ORDER BY CAST(Number AS INTEGER);
|
||||
|
||||
-- List collections with item counts
|
||||
SELECT c.Id, c.Name, COUNT(ci.Id) as items FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
|
||||
|
||||
-- List schedules
|
||||
SELECT Id, Name FROM ProgramSchedule;
|
||||
|
||||
-- Playout (channel-schedule links)
|
||||
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule FROM Playout p JOIN Channel c ON p.ChannelId = c.Id LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id;
|
||||
|
||||
-- Media counts
|
||||
SELECT 'Shows' as type, COUNT(*) FROM Show UNION ALL SELECT 'Movies', COUNT(*) FROM Movie UNION ALL SELECT 'Episodes', COUNT(*) FROM Episode UNION ALL SELECT 'MusicVideos', COUNT(*) FROM MusicVideo;
|
||||
|
||||
-- Jellyfin source
|
||||
SELECT jms.Id, jc.Address, jms.ServerName FROM JellyfinMediaSource jms JOIN JellyfinConnection jc ON jc.JellyfinMediaSourceId = jms.Id;
|
||||
|
||||
-- Library sync status
|
||||
SELECT l.Id, l.Name, l.MediaKind, jl.ShouldSyncItems FROM Library l JOIN JellyfinLibrary jl ON jl.Id = l.Id;
|
||||
```
|
||||
|
||||
### Channel Setup Workflow (DB)
|
||||
|
||||
**Show-specific channel** (single TV show, shuffled):
|
||||
```sql
|
||||
-- 1. Schedule
|
||||
INSERT INTO ProgramSchedule (Id, FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, Name, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
|
||||
VALUES (<id>, 0, 0, '<name>', 1, 0, 1);
|
||||
-- 2. Schedule item (CollectionType=1 for Show, PlaybackOrder=3 for Shuffle)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, MediaItemId, PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, 1, 0, 0, 0, 0, 0, 0, <show_id>, 3, <schedule_id>);
|
||||
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
|
||||
-- 3. Channel
|
||||
INSERT INTO Channel (Id, Categories, FFmpegProfileId, FallbackFillerId, "Group", IdleBehavior, IsEnabled, MirrorSourceChannelId, MusicVideoCreditsMode, MusicVideoCreditsTemplate, Name, Number, PlayoutMode, PlayoutOffset, PlayoutSource, PreferredAudioLanguageCode, PreferredAudioTitle, PreferredSubtitleLanguageCode, ShowInEpg, SongVideoMode, SortNumber, StreamSelector, StreamSelectorMode, StreamingMode, SubtitleMode, TranscodeMode, UniqueId, WatermarkId)
|
||||
VALUES (<id>, '', 1, NULL, '<category>', 0, 1, NULL, 0, NULL, '<name>', '<number>', 0, NULL, 0, NULL, NULL, 'eng', 1, 0, <number>.0, NULL, 0, 4, 2, 0, lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))), 1);
|
||||
-- 4. Playout
|
||||
INSERT INTO Playout (Id, ChannelId, ProgramScheduleId, ScheduleKind, Seed)
|
||||
VALUES (<id>, <channel_id>, <schedule_id>, 0, abs(random()) % 1000000);
|
||||
```
|
||||
|
||||
**Collection-based channel** (multiple shows, shuffled):
|
||||
```sql
|
||||
-- 1. Collection + items (MediaItemId = Show.Id)
|
||||
INSERT INTO Collection (Id, Name, UseCustomPlaybackOrder) VALUES (<id>, '<name>', 0);
|
||||
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <show_id>);
|
||||
-- 2. Schedule (same as above but CollectionType=0, CollectionId set instead of MediaItemId)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, ..., PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, <coll_id>, 0, ..., 3, <schedule_id>);
|
||||
-- 3-4. Channel + Playout same as show-specific
|
||||
```
|
||||
|
||||
After creating: `POST /api/channels/{number}/playout/reset`
|
||||
|
||||
## Volume Mounts (matches Jellyfin)
|
||||
|
||||
| Host Path | Container Path |
|
||||
|-----------|---------------|
|
||||
| `~/downloadswarm/ersatztv` | `/config` |
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` (ro) |
|
||||
| `/mnt/episodes` | `/data/episodes` (ro) |
|
||||
| `/mnt/media/movies` | `/data/movies` (ro) |
|
||||
| `/mnt/media/standup` | `/data/standup` (ro) |
|
||||
| `/mnt/media/music_videos` | `/data/music` (ro) |
|
||||
|
||||
## FFmpeg & Hardware
|
||||
|
||||
- QSV (Intel Quick Sync) hardware acceleration
|
||||
- Resolution: 1920x1080, H264, AAC stereo
|
||||
- Device: `/dev/dri` passed through
|
||||
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf
|
||||
|
||||
## Jellyfin Integration
|
||||
|
||||
- Secrets: `/config/jellyfin-secrets.json` (`{"Address":"http://jellyfin:8096","ApiKey":"978033be716d46678a5d3c54ae0e0ff9"}`)
|
||||
- Libraries: Movies(10), TV Shows(11), Music Videos(8), Standup(9)
|
||||
- `JellyfinLibrary.ShouldSyncItems` must be `1` for scans to work
|
||||
|
||||
## Gotchas
|
||||
|
||||
- DB owned by root — always use `sudo sqlite3`
|
||||
- WAL mode: reads OK while running, stop container for writes
|
||||
- No REST API for channel/collection/schedule CRUD — DB scripting only
|
||||
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
|
||||
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
|
||||
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
|
||||
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
|
||||
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
|
||||
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
|
||||
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
|
||||
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
|
||||
- MediaItem.State: 0=Normal, 1=FileNotFound — clean up state=1 items by deleting cascading deps
|
||||
- ProgramSchedule required NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows
|
||||
- Channel required NOT NULL columns: SongVideoMode (set 0), plus all standard columns (see Channel table schema)
|
||||
- After schedule changes, rebuild playout: `POST /api/channels/{number}/playout/reset`
|
||||
- Playout `ScheduleKind` must be `1` (not `0`/None) — `0` causes "Cannot build playout type None" error
|
||||
- M3U `tvg-logo` URLs hardcode `http://localhost:8409` — Jellyfin can't fetch these from inside its container. Fix by downloading logos from ETV and base64-uploading to Jellyfin (see `docs/Docker/ErsatzTV.md` for script). Tracked in issue #171
|
||||
- Repo archived Feb 2026, v26.3.0 is final stable version. Maintainer welcomes forks
|
||||
105
.claude/skills/jellyfin/SKILL.md
Normal file
105
.claude/skills/jellyfin/SKILL.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: jellyfin
|
||||
description: Jellyfin media server management — API for libraries, items, streaming, users. Use when managing media library or checking Jellyfin status.
|
||||
---
|
||||
|
||||
# Jellyfin Management
|
||||
|
||||
Container: `jellyfin` | Port: `8096` | IP: `172.16.238.20` (may change on restart)
|
||||
API Token: `978033be716d46678a5d3c54ae0e0ff9`
|
||||
Web UI: `https://jellyfin.tblindustries.be` (NO Authelia — native login, password: `coup1802`)
|
||||
Config: `/home/timothy/downloadswarm/jellyfin/` on jazz
|
||||
|
||||
## Access Pattern
|
||||
|
||||
```bash
|
||||
docker exec jellyfin curl -s 'http://localhost:8096/ENDPOINT' \
|
||||
-H 'X-Emby-Token: 978033be716d46678a5d3c54ae0e0ff9'
|
||||
```
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
| Host Path | Container Path | Content |
|
||||
|-----------|---------------|---------|
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` | TV shows |
|
||||
| `/mnt/episodes` | `/data/episodes` | More episodes |
|
||||
| `/mnt/media/movies` | `/data/movies` | Movies |
|
||||
| `/mnt/media/standup` | `/data/standup` | Standup |
|
||||
| `/mnt/media/music_videos` | `/data/music` | Music videos |
|
||||
| `/mnt/media/audio/music` | `/data/audio` | Music audio (ro) |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### System
|
||||
```
|
||||
GET /System/Info # Server info, version
|
||||
GET /System/Info/Public # Public info (no auth needed)
|
||||
POST /System/Restart # Restart server
|
||||
```
|
||||
|
||||
### Items (Search & Browse)
|
||||
```bash
|
||||
# Search items
|
||||
GET /Items?includeItemTypes=Movie,Episode,Series&recursive=true&searchTerm=QUERY&fields=Path&limit=20
|
||||
|
||||
# Get item details
|
||||
GET /Items?ids=ITEM_ID&fields=Path,MediaStreams,Overview
|
||||
|
||||
# Get all movies
|
||||
GET /Items?includeItemTypes=Movie&recursive=true&fields=Path&limit=1000
|
||||
|
||||
# Get series
|
||||
GET /Items?includeItemTypes=Series&recursive=true&fields=Path
|
||||
|
||||
# Get episodes for a series
|
||||
GET /Shows/{seriesId}/Episodes?fields=Path,MediaStreams
|
||||
|
||||
# Filter by library (parentId)
|
||||
GET /Items?parentId=LIBRARY_ID&recursive=true&fields=Path
|
||||
```
|
||||
|
||||
### Libraries
|
||||
```
|
||||
GET /Library/VirtualFolders # List all libraries
|
||||
POST /Library/Refresh # Trigger full library scan
|
||||
POST /Items/{id}/Refresh # Refresh single item metadata
|
||||
```
|
||||
|
||||
### Streaming
|
||||
```bash
|
||||
# Test stream URL
|
||||
GET /Videos/{itemId}/stream?static=true
|
||||
|
||||
# Get playback info
|
||||
GET /Items/{itemId}/PlaybackInfo
|
||||
```
|
||||
|
||||
### Users
|
||||
```
|
||||
GET /Users # List users
|
||||
GET /Users/{userId} # User details
|
||||
```
|
||||
|
||||
## Library IDs
|
||||
|
||||
Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/VirtualFolders`
|
||||
|
||||
## Live TV
|
||||
|
||||
- **ErsatzTV** (channels <1000): M3U `http://ersatztv:8409/iptv/channels.m3u`, XMLTV `http://ersatztv:8409/iptv/xmltv.xml`
|
||||
- **Dispatcharr** (channels 1000+): IPTV stream manager on port 9191, separate tuner
|
||||
- Configured in Jellyfin Admin > Live TV
|
||||
- Guide refresh task ID: `bea9b218c97bbf98c5dc1303bdb9a0ca` — trigger via `POST /ScheduledTasks/Running/{id}`
|
||||
- **Logo fix after guide refresh**: ErsatzTV logos break (aspect ratio=0) because M3U uses `localhost:8409`. Fix script in `docs/Docker/ErsatzTV.md` downloads from ETV and base64-uploads to `POST /Items/{id}/Images/Primary` (body = base64, Content-Type = image/png)
|
||||
- **Image upload format**: Jellyfin expects base64-encoded body (NOT raw binary) for `POST /Items/{id}/Images/Primary`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
|
||||
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
|
||||
- Music videos are typed as "Movie" in Jellyfin
|
||||
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
|
||||
- Items return 404 on stream if source volume is unmounted
|
||||
- Jellyfin preserves item IDs across restarts unless files are renamed
|
||||
- Full library scan can take a long time — prefer targeted `/Items/{id}/Refresh`
|
||||
- `ffprobe` available in container for checking media streams: `docker exec jellyfin ffprobe -v quiet -print_format json -show_streams FILE`
|
||||
@@ -3,11 +3,11 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.1.4",
|
||||
"version": "2025.3.0.2",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
[*]
|
||||
charset=utf-8
|
||||
end_of_line=lf
|
||||
trim_trailing_whitespace=true
|
||||
insert_final_newline=false
|
||||
insert_final_newline=true
|
||||
indent_style=space
|
||||
indent_size=4
|
||||
|
||||
@@ -15,7 +14,7 @@ csharp_style_expression_bodied_constructors=true:none
|
||||
csharp_style_expression_bodied_methods=true:none
|
||||
csharp_style_expression_bodied_properties=true:suggestion
|
||||
csharp_style_var_elsewhere=false:suggestion
|
||||
csharp_style_var_for_built_in_types=false:suggestion
|
||||
csharp_style_var_for_built_in_types=false:none
|
||||
csharp_style_var_when_type_is_apparent=true:suggestion
|
||||
dotnet_naming_rule.local_constants_rule.severity=warning
|
||||
dotnet_naming_rule.local_constants_rule.style=all_upper_style
|
||||
@@ -42,6 +41,8 @@ resharper_braces_for_for=required
|
||||
resharper_braces_for_foreach=required
|
||||
resharper_braces_for_ifelse=required
|
||||
resharper_braces_for_while=required
|
||||
resharper_csharp_arguments_literal=positional
|
||||
resharper_csharp_arguments_named=positional
|
||||
resharper_csharp_insert_final_newline=true
|
||||
resharper_csharp_max_attribute_length_for_same_line=0
|
||||
resharper_csharp_place_accessorholder_attribute_on_same_line=never
|
||||
@@ -66,7 +67,7 @@ resharper_built_in_type_reference_style_highlighting=hint
|
||||
resharper_redundant_base_qualifier_highlighting=warning
|
||||
resharper_suggest_var_or_type_built_in_types_highlighting=hint
|
||||
resharper_suggest_var_or_type_elsewhere_highlighting=hint
|
||||
resharper_suggest_var_or_type_simple_types_highlighting=hint
|
||||
resharper_suggest_var_or_type_simple_types_highlighting=none
|
||||
resharper_web_config_module_not_resolved_highlighting=warning
|
||||
resharper_web_config_type_not_resolved_highlighting=warning
|
||||
resharper_web_config_wrong_module_highlighting=warning
|
||||
@@ -84,7 +85,22 @@ tab_width=4
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
ij_json_array_wrapping = normal
|
||||
ij_json_keep_blank_lines_in_code = 0
|
||||
ij_json_keep_indents_on_empty_lines = false
|
||||
ij_json_keep_line_breaks = true
|
||||
ij_json_keep_trailing_comma = false
|
||||
ij_json_object_wrapping = normal
|
||||
ij_json_property_alignment = do_not_align
|
||||
ij_json_space_after_colon = true
|
||||
ij_json_space_after_comma = true
|
||||
ij_json_space_before_colon = false
|
||||
ij_json_space_before_comma = false
|
||||
ij_json_spaces_within_braces = true
|
||||
ij_json_spaces_within_brackets = true
|
||||
ij_json_wrap_long_lines = false
|
||||
|
||||
[*.cs]
|
||||
# disable CA1848: Use the LoggerMessage delegates`
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Requests
|
||||
url: https://features.ersatztv.org
|
||||
about: Features
|
||||
- name: Contact
|
||||
url: https://ersatztv.org/contact
|
||||
about: Chat Options
|
||||
- name: Community
|
||||
url: https://discuss.ersatztv.org
|
||||
about: Forum
|
||||
- name: Discussions
|
||||
url: https://github.com/ErsatzTV/ErsatzTV/discussions
|
||||
about: Discuss
|
||||
77
.github/ISSUE_TEMPLATE/issue.yml
vendored
Normal file
77
.github/ISSUE_TEMPLATE/issue.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Issue Report
|
||||
description: Report an issue
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this form! Please make sure to fill all fields, including the Title above.
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
|
||||
options:
|
||||
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://ersatztv.org/contact) first to troubleshoot with volunteers before creating a report.
|
||||
required: true
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/ErsatzTV/ErsatzTV/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I'm using an up to date version of ErsatzTV (full release or develop release); We generally do not support previous older versions. If possible, please update to the latest version before opening an issue.
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, please create separate reports for each one.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
Description of the problem or issue here.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro-steps
|
||||
attributes:
|
||||
label: Steps to reproduce the problem.
|
||||
description: |
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
3. Step 3
|
||||
|
||||
If this is a playback issue, follow these steps and post the resulting zip:
|
||||
1. Search for the required content using the search bar.
|
||||
2. Use the overflow/three dots menu on the content and select Troubleshoot Playback.
|
||||
3. Select the appropriate Playback Settings that trigger the undesired behavior.
|
||||
4. Click Play to start playback.
|
||||
5. Repeat steps 3 and 4 until the undesired behavior is reproduced.
|
||||
6. Click Download Results to have ErsatzTV collect relevant troubleshooting logs (ffmpeg log, ffmpeg profile, hardware capabilities, media info, etc) and compress them in a zip file.
|
||||
7. Attach the zip to this field.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: What is the current _bug_ behavior?
|
||||
description: Write down the incorrect behavior that currently happens after following the reproduction steps.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: What is the expected _correct_ behavior?
|
||||
description: Write down the correct expected behavior that is supposed to happen after following the reproduction steps.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Specify full version
|
||||
description: Provide the full version of ErsatzTV, which can be found below the left menu.
|
||||
placeholder: |
|
||||
25.5.0-bd695412-docker-amd64
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any additional information that might be useful to this issue.
|
||||
196
.github/workflows/artifacts.yml
vendored
196
.github/workflows/artifacts.yml
vendored
@@ -25,11 +25,19 @@ on:
|
||||
required: true
|
||||
gh_token:
|
||||
required: true
|
||||
azure_client_id:
|
||||
required: true
|
||||
azure_tenant_id:
|
||||
required: true
|
||||
azure_subscription_id:
|
||||
required: true
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
jobs:
|
||||
build_and_upload_mac:
|
||||
name: Mac Build & Upload
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -46,10 +54,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
- 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
|
||||
@@ -73,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
|
||||
@@ -126,22 +134,20 @@ jobs:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: |
|
||||
*${{ matrix.target }}.dmg
|
||||
assets: "*${{ matrix.target }}.dmg"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.dmg
|
||||
files: "${{ env.RELEASE_NAME }}.dmg"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
build_and_upload:
|
||||
name: Build & Upload
|
||||
|
||||
build_and_upload_linux:
|
||||
name: Build & Upload Linux
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -157,19 +163,16 @@ jobs:
|
||||
- os: ubuntu-24.04-arm
|
||||
kind: linux
|
||||
target: linux-arm64
|
||||
- os: windows-latest
|
||||
kind: windows
|
||||
target: win-x64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET Core
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -177,14 +180,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target }}"
|
||||
|
||||
- uses: suisei-cn/actions-download-file@v1.3.0
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
id: downloadffmpeg
|
||||
name: Download ffmpeg
|
||||
with:
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-56-gc2184b65d2-win64-gpl-7.1.zip"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -194,31 +189,12 @@ 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/"
|
||||
|
||||
# Build Windows launcher
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
|
||||
ls -l ErsatzTV-Windows/target/release
|
||||
mv ErsatzTV-Windows/target/release/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
fi
|
||||
|
||||
# Download ffmpeg
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
fi
|
||||
|
||||
# Pack files
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
else
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
fi
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
|
||||
# Delete output directory
|
||||
rm -r "$release_name"
|
||||
@@ -230,17 +206,131 @@ jobs:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: |
|
||||
*${{ matrix.target }}.zip
|
||||
*${{ matrix.target }}.tar.gz
|
||||
assets: "*${{ matrix.target }}.tar.gz"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.zip
|
||||
${{ env.RELEASE_NAME }}.tar.gz
|
||||
files: "${{ env.RELEASE_NAME }}.tar.gz"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
|
||||
build_dotnet_windows:
|
||||
name: Build dotnet for Windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "win-x64"
|
||||
|
||||
- name: Build dotnet projects
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Upload .NET Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dotnet-windows-build
|
||||
path: |
|
||||
scanner/
|
||||
main/
|
||||
retention-days: 1
|
||||
|
||||
package_and_upload_windows:
|
||||
name: Package & Upload Windows
|
||||
runs-on: windows-latest
|
||||
needs: build_dotnet_windows
|
||||
steps:
|
||||
- name: Download dotnet artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dotnet-windows-build
|
||||
path: dotnet-build
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.azure_client_id }}
|
||||
tenant-id: ${{ secrets.azure_tenant_id }}
|
||||
subscription-id: ${{ secrets.azure_subscription_id }}
|
||||
enable-AzPSSession: true
|
||||
|
||||
- name: Sign dotnet artifacts
|
||||
uses: azure/trusted-signing-action@v0
|
||||
with:
|
||||
endpoint: https://eus.codesigning.azure.net/
|
||||
trusted-signing-account-name: ArtifactSigning
|
||||
certificate-profile-name: ErsatzTV
|
||||
files-folder: ${{ github.workspace }}/dotnet-build
|
||||
files-folder-recurse: true
|
||||
files-folder-filter: ErsatzTV.exe,ErsatzTV.Scanner.exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Download rust launcher
|
||||
uses: suisei-cn/actions-download-file@v1.3.0
|
||||
with:
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-Windows/releases/download/v1.0.0/ErsatzTV-Windows.exe"
|
||||
target: rust-build/
|
||||
|
||||
- name: Download ffmpeg
|
||||
uses: suisei-cn/actions-download-file@v1.3.0
|
||||
id: downloadffmpeg
|
||||
with:
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-56-gc2184b65d2-win64-gpl-7.1.zip"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-win-x64"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
mkdir "$release_name"
|
||||
|
||||
mv dotnet-build/scanner/* "$release_name/"
|
||||
mv dotnet-build/main/* "$release_name/"
|
||||
|
||||
# dotnet shouldn't copy the resources here, but it does
|
||||
rm -rf "$release_name/Resources"
|
||||
|
||||
mv rust-build/ErsatzTV-Windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
|
||||
(cd "${release_name}" && 7z a "../${release_name}.zip" .)
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: "*win-x64.zip"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: "${{ env.RELEASE_NAME }}.zip"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
|
||||
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
|
||||
|
||||
42
.github/workflows/pr.yml
vendored
42
.github/workflows/pr.yml
vendored
@@ -2,16 +2,41 @@
|
||||
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:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET Core
|
||||
- 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:
|
||||
@@ -49,10 +69,10 @@ jobs:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET Core
|
||||
- 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
|
||||
@@ -77,10 +97,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
- 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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,9 @@
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
|
||||
56
.mcp.json
Normal file
56
.mcp.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"docker-mcp": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-docker"
|
||||
],
|
||||
"env": {
|
||||
"DOCKER_HOST": "ssh://timothy@192.168.1.99"
|
||||
}
|
||||
},
|
||||
"ssh-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"ssh-mcp",
|
||||
"--",
|
||||
"--host=192.168.1.99",
|
||||
"--user=timothy"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"gitea": {
|
||||
"command": "gitea-mcp-server",
|
||||
"args": [
|
||||
"-t", "stdio",
|
||||
"-host", "http://192.168.1.95:3000",
|
||||
"-token", "8341af0733ab9ce084ea7adf38b76aa9ebc3bd67"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"csharp-lsp": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"run",
|
||||
"--project", "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp",
|
||||
"-c", "Release"
|
||||
],
|
||||
"env": {
|
||||
"PATH": "/usr/local/share/dotnet:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
}
|
||||
},
|
||||
"nuget": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"dnx",
|
||||
"NuGet.Mcp.Server",
|
||||
"--source", "https://api.nuget.org/v3/index.json",
|
||||
"--yes"
|
||||
],
|
||||
"env": {
|
||||
"DOTNET_ROOT": "/usr/local/share/dotnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
806
CHANGELOG.md
806
CHANGELOG.md
@@ -4,6 +4,781 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
- Remove BugSnag error reporting integration
|
||||
- Remove developer's personal Trakt API key
|
||||
- Users who want to continue to use Trakt must create an API app and set the `Client ID` as the environment variable `TRAKT__CLIENTID`
|
||||
|
||||
### Fixed
|
||||
- Support adding trakt lists using `app.trakt.tv` domain (instead of just `trakt.tv`)
|
||||
|
||||
## [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
|
||||
- This should eliminate the need to lookup media ids for content
|
||||
- Add subtitle selection to playback troubleshooting. This is limited to:
|
||||
- Sidecar text subtitles (e.g. `srt` files)
|
||||
- Embedded image subtitles
|
||||
- Embedded text subtitles that have already been extracted by ETV
|
||||
- Add light mode and light/dark mode toggle to app bar
|
||||
- YAML playout: add `pre_roll` instruction to enable and disable a pre-roll sequence
|
||||
- With value of `true` and `sequence` property, will enable automatic pre-roll for all content in the playout to the sequence with the provided key
|
||||
- With value of `false`, will disable automatic pre-roll in the playout
|
||||
- YAML playout: add `post_roll` instruction to enable and disable a post-roll sequence
|
||||
- With value of `true` and `sequence` property, will enable automatic post-roll for all content in the playout to the sequence with the provided key
|
||||
- With value of `false`, will disable automatic post-roll in the playout
|
||||
- YAML playout: add `mid_roll` instruction to enable and disable a mid-roll sequence
|
||||
- With value of `true` and `sequence` property, will enable automatic mid-roll for (`count` and `all`) content in the playout to the sequence with the provided key
|
||||
- With value of `false`, will disable automatic post-roll in the playout
|
||||
- `expression` can be used to influence which chapters are selected for mid roll (same as in filler preset)
|
||||
- YAML playout: add `rewind` instruction to set start of playout relative to the current time
|
||||
- Value should be formatted as `HH:MM:SS` e.g. `00:05:30` for 5 minutes 30 seconds (before now)
|
||||
- This is instruction is mostly useful for debugging transitions, and can only be used as a reset instruction
|
||||
- YAML playout: add `import` section to allow importing partial YAML definitions that include `content` and `sequence` entries
|
||||
- Add YAML playout validation (using JSON Schema)
|
||||
- Invalid YAML playout definitions will fail to build and will log validation failures as warnings
|
||||
- `content` is fully validated
|
||||
- `sequence` is fully validated
|
||||
- `reset` is fully validated
|
||||
- `playout` is fully validated
|
||||
- Add `Playlist` collection type to filler presets
|
||||
- This will force filler mode `Count`
|
||||
- Whenever the filler is used, it will schedule `Count` times full time through the playlist
|
||||
- If the playlist has 3 items and none set to play all, it will schedule 3 items when `Count = 1`
|
||||
- If the playlist has 3 items and none set to play all, it will schedule 6 items when `Count = 2`
|
||||
- Using the same playlist in the same schedule for anything other than filler may cause undesired behavior
|
||||
- Detect supported VideoToolbox hardware decoders and encoders
|
||||
- Software decoders/encoders will automatically be used when hardware versions are unavailable
|
||||
- Add VideoToolbox Capabilities to Troubleshooting page
|
||||
- Add `Use Chapters As Media Items` option to filler preset
|
||||
- This option allows scheduling individual chapters as filler
|
||||
- The chapters are shuffled or otherwise sorted together just like normal filler would be
|
||||
- Add smart collection edit page to allow renaming smart collections
|
||||
- Previous edit link behavior (performing search using smart collection query) now uses magnifying glass icon
|
||||
- Add channel `Transcode Mode` setting
|
||||
- This setting is currently disabled and only has the value `On Demand`
|
||||
- Add channel `Idle Behavior` setting to control the transcoding behavior after all clients have disconnected
|
||||
- `Stop On Disconnect` - stops the transcoder after all clients have disconnected + the global idle timeout
|
||||
- `Keep Running` - transcoder will run until manually stopped
|
||||
- Add support for music video thumbnails that end in `-thumb`
|
||||
- For example `Music Video.mkv` could have a corresponding thumbnail `Music Video-thumb.jpg`
|
||||
- Reorganize troubleshooting page
|
||||
- Add `YAML Validation` tool in `Troubleshooting` > `Tools`
|
||||
|
||||
### Fixed
|
||||
- Fix app startup with MySql/MariaDB
|
||||
- YAML playout: fix `pad_to_next` always running over time
|
||||
- Fix playback with text subtitles when seeking into content, i.e. when first joining a channel
|
||||
- Fix playback with `.ass` and `.ssa` text subtitles
|
||||
- Fix green padding with 10-bit source content and i965 VAAPI driver
|
||||
- Fix building playouts with empty schedules
|
||||
- Fix schedule start time calculation when daily playout build goes beyond midnight and into a different alternate schedule
|
||||
- Fix compatibility with older NVIDIA devices (compute capability 3.0+) in unified docker image
|
||||
- Fix transitions when using NVIDIA, QSV and VAAPI acceleration
|
||||
- Fix playback of remote streams on channels where framerate normalization is enabled
|
||||
|
||||
### Changed
|
||||
- Always tell ffmpeg to stop encoding with a specific duration
|
||||
- This was removed to try to improve transitions with ffmpeg 7.x, but has been causing issues with other content
|
||||
- Move search debug logging to its own log category; add `Searching Minimum Log Level` to `Settings` > `Logging`
|
||||
- Classic schedules: always schedule the full `Duration` amount instead of stopping mid-duration
|
||||
- This allows duration items to be scheduled beyond midnight
|
||||
- e.g. fixed start time 22:00 with 4 hour duration will schedule until 02:00 instead of stopping at midnight
|
||||
- Rename channel setting `Progress Mode` to `Playout Mode`
|
||||
- This controls the progression of the channel's playout, and has nothing to do with transcoding
|
||||
- `Always` is now called `Continuous` (playout progresses with wall clock)
|
||||
- `On Demand` is unchanged (playout only progresses while a client is watching the channel)
|
||||
- Replace channel `Active Mode` setting with new `Is Enabled` and `Show In EPG` settings
|
||||
- `Active` channels will be converted to `Is Enabled` = true and `Show In EPG` = true
|
||||
- `Hidden` channels will be converted to `Is Enabled` = true and `Show In EPG` = false
|
||||
- `Inactive` channels will be converted to `Is Enabled` = false and `Show In EPG` = false
|
||||
|
||||
## [25.3.1] - 2025-07-24
|
||||
### Fixed
|
||||
@@ -61,13 +836,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
|
||||
@@ -1869,7 +2644,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
|
||||
@@ -2424,7 +3199,18 @@ 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.3.1...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
|
||||
[25.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.1.0...v25.2.0
|
||||
@@ -2553,4 +3339,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
|
||||
|
||||
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# ErsatzTV Fork
|
||||
|
||||
Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https://github.com/ErsatzTV/ErsatzTV) after upstream archival (Feb 2026, v26.3.0). Our fork lives on [Gitea](http://192.168.1.95:3000/timothy/ersatztv).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Language**: C# / .NET 10, Blazor Server UI (MudBlazor)
|
||||
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
|
||||
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
|
||||
- **Functional C#**: Language Ext (Option, Either monads throughout)
|
||||
|
||||
### Project Layout
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `ErsatzTV/` | ASP.NET Core host, Blazor pages, API controllers, DI setup |
|
||||
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
|
||||
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
|
||||
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
|
||||
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
|
||||
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
|
||||
| `ErsatzTV.Scanner/` | Media library scanning |
|
||||
|
||||
### Key Files
|
||||
|
||||
- **M3U generation**: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs` → `ToM3U()`
|
||||
- **XMLTV generation**: `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`
|
||||
- **IPTV controller**: `ErsatzTV/Controllers/IptvController.cs` — `/iptv/*` routes
|
||||
- **Logo generation**: `ErsatzTV.Core/Images/ChannelLogoGenerator.cs`
|
||||
- **Channel entities**: `ErsatzTV.Core/Domain/Channel.cs`
|
||||
- **DB context**: `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
|
||||
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
|
||||
- **Image**: `ghcr.io/ersatztv/ersatztv:latest` (to be replaced with our fork's image)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build ErsatzTV.sln
|
||||
|
||||
# Run locally (needs FFmpeg in PATH)
|
||||
dotnet run --project ErsatzTV
|
||||
|
||||
# Docker build
|
||||
docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Follow existing MediatR CQRS pattern for new features
|
||||
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
|
||||
- Keep Blazor pages thin — delegate to MediatR handlers
|
||||
- Test with xUnit (existing test projects)
|
||||
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
|
||||
|
||||
## Task Completion Protocol
|
||||
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
|
||||
|
||||
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
|
||||
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
|
||||
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
|
||||
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
|
||||
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
|
||||
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
|
||||
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
|
||||
|
||||
**ersatztv does NOT own**:
|
||||
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
|
||||
- NFS mounts, Ansible, DNS, networking → server-management
|
||||
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
|
||||
- Jellyfin skill → server-management (symlinked)
|
||||
|
||||
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
|
||||
|
||||
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
|
||||
|
||||
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
|
||||
|
||||
**Full cross-project rules**: `~/homelab-docs/Operations/Project Boundaries.md` (https://docs.tblindustries.be).
|
||||
**ErsatzTV docs**: `~/homelab-docs/Docker/ErsatzTV.md` + project-local `docs/` (fork strategy, channels, M3U/XMLTV).
|
||||
@@ -2,5 +2,6 @@
|
||||
<PropertyGroup>
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
15
Directory.Build.targets
Normal file
15
Directory.Build.targets
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.VisualStudio.Threading.Analyzers"
|
||||
Version="17.14.15"
|
||||
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
2
ErsatzTV-Windows/.gitignore
vendored
2
ErsatzTV-Windows/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
target/
|
||||
|
||||
1035
ErsatzTV-Windows/Cargo.lock
generated
1035
ErsatzTV-Windows/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "ersatztv_windows"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tray-item = { git = "https://github.com/olback/tray-item-rs" }
|
||||
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
|
||||
process_path = "0.1.4"
|
||||
|
||||
[dependencies.windows]
|
||||
version = "0.43.0"
|
||||
features = [
|
||||
"Win32_System_Console",
|
||||
"Win32_Foundation"
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
windres = "*"
|
||||
static_vcruntime = "2.0"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,6 +0,0 @@
|
||||
use windres::Build;
|
||||
|
||||
fn main() {
|
||||
static_vcruntime::metabuild();
|
||||
Build::new().compile("ersatztv_windows.rc").unwrap();
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
id ICON "ersatztv.ico"
|
||||
ersatztv-icon ICON "ersatztv.ico"
|
||||
@@ -1,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,
|
||||
ChannelProgressMode ProgressMode,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -26,7 +30,10 @@ public record ChannelViewModel(
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelActiveMode ActiveMode)
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg)
|
||||
{
|
||||
public string WebEncodedName => WebUtility.UrlEncode(Name);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
ChannelProgressMode ProgressMode,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -24,4 +28,7 @@ public record CreateChannel(
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -22,7 +23,7 @@ public class CreateChannelHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||
}
|
||||
|
||||
@@ -35,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,
|
||||
ProgressMode = request.ProgressMode,
|
||||
SlugSeconds = request.SlugSeconds,
|
||||
PlayoutSource = request.PlayoutSource,
|
||||
PlayoutMode = request.PlayoutMode,
|
||||
MirrorSourceChannelId = request.MirrorSourceChannelId,
|
||||
PlayoutOffset = request.PlayoutOffset,
|
||||
StreamingMode = request.StreamingMode,
|
||||
Artwork = artwork,
|
||||
StreamSelectorMode = request.StreamSelectorMode,
|
||||
@@ -88,9 +96,22 @@ public class CreateChannelHandler(
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
|
||||
SongVideoMode = request.SongVideoMode,
|
||||
ActiveMode = request.ActiveMode
|
||||
TranscodeMode = request.TranscodeMode,
|
||||
IdleBehavior = request.IdleBehavior,
|
||||
IsEnabled = request.IsEnabled,
|
||||
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;
|
||||
@@ -110,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"),
|
||||
() =>
|
||||
@@ -129,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)
|
||||
@@ -139,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)
|
||||
{
|
||||
@@ -147,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))
|
||||
@@ -156,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)
|
||||
{
|
||||
@@ -165,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 inactiveCount = await dbContext.Channels
|
||||
.Where(c => c.Number == request.ChannelNumber && c.ActiveMode != ChannelActiveMode.Active)
|
||||
.CountAsync(cancellationToken);
|
||||
if (inactiveCount > 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.ActiveMode = 0
|
||||
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,
|
||||
ChannelProgressMode ProgressMode,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -25,4 +29,7 @@ public record UpdateChannel(
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -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;
|
||||
@@ -43,7 +62,10 @@ public class UpdateChannelHandler(
|
||||
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
|
||||
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
|
||||
c.SongVideoMode = update.SongVideoMode;
|
||||
c.ActiveMode = update.ActiveMode;
|
||||
c.TranscodeMode = update.TranscodeMode;
|
||||
c.IdleBehavior = update.IdleBehavior;
|
||||
c.IsEnabled = update.IsEnabled;
|
||||
c.ShowInEpg = update.IsEnabled && update.ShowInEpg;
|
||||
c.Artwork ??= [];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
|
||||
@@ -83,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())
|
||||
{
|
||||
@@ -92,54 +114,125 @@ public class UpdateChannelHandler(
|
||||
}
|
||||
}
|
||||
|
||||
c.ProgressMode = update.ProgressMode;
|
||||
c.PlayoutSource = update.PlayoutSource;
|
||||
c.PlayoutMode = update.PlayoutMode;
|
||||
|
||||
if (c.PlayoutSource is ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
c.PlayoutMode = ChannelPlayoutMode.Continuous;
|
||||
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
|
||||
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
|
||||
}
|
||||
else
|
||||
{
|
||||
c.MirrorSourceChannelId = null;
|
||||
c.PlayoutOffset = null;
|
||||
}
|
||||
|
||||
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
|
||||
c.PlayoutOffset = update.PlayoutOffset;
|
||||
c.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
if (c.SubtitleMode != ChannelSubtitleMode.None)
|
||||
{
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id);
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, cancellationToken);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList());
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
if (hasEpgChange)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
|
||||
}
|
||||
|
||||
return ProjectToViewModel(c);
|
||||
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
|
||||
(await ChannelMustExist(dbContext, request), ValidateName(request),
|
||||
await ValidateNumber(dbContext, request))
|
||||
.Apply((channelToUpdate, _, _) => channelToUpdate);
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ChannelMustExist(dbContext, request, cancellationToken),
|
||||
ValidateName(request),
|
||||
await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
|
||||
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateChannel updateChannel) =>
|
||||
UpdateChannel updateChannel,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Channels
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId)
|
||||
.Include(c => c.Playouts)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Channel does not exist."));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
Option<Channel> maybeMirrorSource = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(
|
||||
c => c.Id == request.MirrorSourceChannelId,
|
||||
c => c.Id == request.MirrorSourceChannelId,
|
||||
cancellationToken);
|
||||
|
||||
if (maybeMirrorSource.IsNone)
|
||||
{
|
||||
return BaseError.New("Mirror source channel does not exist.");
|
||||
}
|
||||
|
||||
foreach (var mirrorSource in maybeMirrorSource)
|
||||
{
|
||||
if (mirrorSource.PlayoutSource is not ChannelPlayoutSource.Generated)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Mirror source channel {mirrorSource.Name} must use generated playout source");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (TimeSpan playoutOffset in Optional(request.PlayoutOffset))
|
||||
{
|
||||
if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12))
|
||||
{
|
||||
return BaseError.New("Playout offset must not be greater than 12 hours");
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(UpdateChannel updateChannel) =>
|
||||
updateChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||
TvContext dbContext,
|
||||
UpdateChannel updateChannel)
|
||||
UpdateChannel updateChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int matchId = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number)
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number, cancellationToken)
|
||||
.Match(c => c.Id, () => updateChannel.ChannelId);
|
||||
|
||||
if (matchId == updateChannel.ChannelId)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record UpdateChannelNumbers(List<ChannelSortViewModel> Channels) : IRequest<Option<BaseError>>;
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class UpdateChannelNumbersHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
: IRequestHandler<UpdateChannelNumbers, Option<BaseError>>
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
|
||||
var channelIds = numberUpdates.Keys;
|
||||
|
||||
List<Channel> channelsToUpdate = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// give every channel a non-conflicting number
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
channel.Number = $"-{channel.Id}";
|
||||
}
|
||||
|
||||
// save those changes
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// give every channel the proper new number
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
channel.Number = numberUpdates[channel.Id];
|
||||
if (double.TryParse(channel.Number, CultureInfo.InvariantCulture, out double sortNumber))
|
||||
{
|
||||
channel.SortNumber = sortNumber;
|
||||
}
|
||||
else
|
||||
{
|
||||
return BaseError.New($"Failed to parse channel number {channel.Number}");
|
||||
}
|
||||
}
|
||||
|
||||
// save those changes
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// commit the transaction
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
// update channel list and xmltv
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), cancellationToken);
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New("Failed to update channel numbers: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,22 +14,29 @@ internal static class Mapper
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfileId,
|
||||
channel.SlugSeconds,
|
||||
GetLogo(channel),
|
||||
channel.StreamSelectorMode,
|
||||
channel.StreamSelector,
|
||||
channel.PreferredAudioLanguageCode,
|
||||
channel.PreferredAudioTitle,
|
||||
channel.ProgressMode,
|
||||
channel.PlayoutSource,
|
||||
channel.PlayoutMode,
|
||||
channel.MirrorSourceChannelId,
|
||||
channel.PlayoutOffset,
|
||||
channel.StreamingMode,
|
||||
channel.WatermarkId,
|
||||
channel.FallbackFillerId,
|
||||
channel.Playouts?.Count ?? 0,
|
||||
playoutCount,
|
||||
channel.PreferredSubtitleLanguageCode,
|
||||
channel.SubtitleMode,
|
||||
channel.MusicVideoCreditsMode,
|
||||
channel.MusicVideoCreditsTemplate,
|
||||
channel.SongVideoMode,
|
||||
channel.ActiveMode);
|
||||
channel.TranscodeMode,
|
||||
channel.IdleBehavior,
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
@@ -43,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)
|
||||
{
|
||||
@@ -69,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,123 +1,118 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, Option<int>>
|
||||
public class GetChannelFramerateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<GetChannelFramerateHandler> logger)
|
||||
: IRequestHandler<GetChannelFramerate, Option<FrameRate>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILogger<GetChannelFramerateHandler> _logger;
|
||||
|
||||
public GetChannelFramerateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<GetChannelFramerateHandler> logger)
|
||||
public async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
FFmpegProfile ffmpegProfile = await dbContext.Channels
|
||||
.Filter(c => c.Number == request.ChannelNumber)
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Map(c => c.FFmpegProfile)
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
if (!ffmpegProfile.NormalizeFramerate)
|
||||
try
|
||||
{
|
||||
return Option<int>.None;
|
||||
}
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// TODO: expand to check everything in collection rather than what's scheduled?
|
||||
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
FFmpegProfile ffmpegProfile = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Filter(c => c.Number == request.ChannelNumber)
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Map(c => c.FFmpegProfile)
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.Filter(p => p.Channel.Number == request.ChannelNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
|
||||
.Flatten()
|
||||
.Map(mv => mv.RFrameRate)
|
||||
.ToList();
|
||||
|
||||
var distinct = frameRates.Distinct().ToList();
|
||||
if (distinct.Count > 1)
|
||||
{
|
||||
// TODO: something more intelligent than minimum framerate?
|
||||
int result = frameRates.Map(ParseFrameRate).Min();
|
||||
if (result < 24)
|
||||
if (!ffmpegProfile.NormalizeFramerate)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
24,
|
||||
result);
|
||||
|
||||
return 24;
|
||||
return Option<FrameRate>.None;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
result);
|
||||
return result;
|
||||
}
|
||||
// TODO: expand to check everything in collection rather than what's scheduled?
|
||||
logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
|
||||
if (distinct.Count != 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Image).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as RemoteStream).MediaVersions)
|
||||
.Filter(p => p.Channel.Number == request.ChannelNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
|
||||
.Flatten()
|
||||
.Map(mv => new FrameRate(mv.RFrameRate))
|
||||
.ToList();
|
||||
|
||||
var distinct = frameRates.Distinct().ToList();
|
||||
if (distinct.Count > 1)
|
||||
{
|
||||
// TODO: something more intelligent than minimum framerate?
|
||||
var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList();
|
||||
if (validFrameRates.Count > 0)
|
||||
{
|
||||
FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate);
|
||||
logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct.Map(fr => fr.RFrameRate),
|
||||
result.RFrameRate);
|
||||
return result;
|
||||
}
|
||||
|
||||
FrameRate minFrameRate = frameRates.MinBy(fr => fr.ParsedFrameRate);
|
||||
logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct.Map(fr => fr.RFrameRate),
|
||||
FrameRate.DefaultFrameRate.RFrameRate,
|
||||
minFrameRate.RFrameRate);
|
||||
|
||||
return FrameRate.DefaultFrameRate;
|
||||
}
|
||||
|
||||
if (distinct.Count != 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0].RFrameRate);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation(
|
||||
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Unexpected error checking frame rates on channel {ChannelNumber}",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private int ParseFrameRate(string frameRate)
|
||||
{
|
||||
if (!int.TryParse(frameRate, out int fr))
|
||||
{
|
||||
string[] split = (frameRate ?? string.Empty).Split("/");
|
||||
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
|
||||
{
|
||||
fr = (int)Math.Round(left / (double)right);
|
||||
}
|
||||
else
|
||||
{
|
||||
fr = 24;
|
||||
}
|
||||
}
|
||||
|
||||
return fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -10,41 +11,32 @@ using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
GetChannelGuide request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var inactiveChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ActiveMode != ChannelActiveMode.Active)
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
.AsEnumerable()
|
||||
.Select(n => $"{n}.xml")
|
||||
.ToImmutableHashSet();
|
||||
|
||||
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!_localFileSystem.FileExists(channelsFile))
|
||||
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!fileSystem.File.Exists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
|
||||
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
|
||||
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
@@ -52,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
|
||||
@@ -61,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 (inactiveChannelNumbers.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();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Hdhr;
|
||||
using ErsatzTV.Core.Hdhr;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
@@ -11,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()
|
||||
.Map(channels => channels.Where(c => c.ActiveMode is ChannelActiveMode.Active)
|
||||
_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.ActiveMode is not ChannelActiveMode.Active)
|
||||
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,31 +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, 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;
|
||||
|
||||
@@ -7,6 +7,7 @@ public class LoggingSettingsViewModel
|
||||
public LogEventLevel DefaultMinimumLogLevel { get; set; }
|
||||
public LogEventLevel ScanningMinimumLogLevel { get; set; }
|
||||
public LogEventLevel SchedulingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel SearchingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel StreamingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel HttpMinimumLogLevel { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ public class PlayoutSettingsViewModel
|
||||
{
|
||||
public int DaysToBuild { get; set; }
|
||||
public bool SkipMissingItems { get; set; }
|
||||
public int ScriptedScheduleTimeoutSeconds { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@ public class GetConfigElementByKeyHandler : IRequestHandler<GetConfigElementByKe
|
||||
public Task<Option<ConfigElementViewModel>> Handle(
|
||||
GetConfigElementByKey request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetConfigElement(request.Key).MapT(ProjectToViewModel);
|
||||
_configElementRepository.GetConfigElement(request.Key, cancellationToken).MapT(ProjectToViewModel);
|
||||
}
|
||||
|
||||
@@ -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,25 +14,39 @@ 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,
|
||||
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
|
||||
{
|
||||
DefaultMinimumLogLevel = await maybeDefaultLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
ScanningMinimumLogLevel = await maybeScanningLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
SchedulingMinimumLogLevel = await maybeSchedulingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
SearchingMinimumLogLevel = await maybeSearchingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
StreamingMinimumLogLevel = await maybeStreamingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
HttpMinimumLogLevel = await maybeHttpLevel.IfNoneAsync(LogEventLevel.Information)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetMpegTsScripts : IRequest<List<MpegTsScript>>;
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetMpegTsScriptsHandler(IMpegTsScriptService mpegTsScriptService)
|
||||
: IRequestHandler<GetMpegTsScripts, List<MpegTsScript>>
|
||||
{
|
||||
public async Task<List<MpegTsScript>> Handle(GetMpegTsScripts request, CancellationToken cancellationToken)
|
||||
{
|
||||
await mpegTsScriptService.RefreshScripts();
|
||||
return mpegTsScriptService.GetScripts().OrderBy(x => x.Name).ToList();
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,23 @@ public class GetPlayoutSettingsHandler : IRequestHandler<GetPlayoutSettings, Pla
|
||||
|
||||
public async Task<PlayoutSettingsViewModel> Handle(GetPlayoutSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
|
||||
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.PlayoutDaysToBuild,
|
||||
cancellationToken);
|
||||
|
||||
Option<bool> skipMissingItems =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems);
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems, cancellationToken);
|
||||
|
||||
Option<int> scriptedScheduleTimeoutSeconds =
|
||||
await _configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.PlayoutScriptedScheduleTimeoutSeconds,
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutSettingsViewModel
|
||||
{
|
||||
DaysToBuild = await daysToBuild.IfNoneAsync(2),
|
||||
SkipMissingItems = await skipMissingItems.IfNoneAsync(false)
|
||||
SkipMissingItems = await skipMissingItems.IfNoneAsync(false),
|
||||
ScriptedScheduleTimeoutSeconds = await scriptedScheduleTimeoutSeconds.IfNoneAsync(30)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetUiSettings : IRequest<UiSettingsViewModel>;
|
||||
@@ -0,0 +1,25 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetUiSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetUiSettings, UiSettingsViewModel>
|
||||
{
|
||||
public async Task<UiSettingsViewModel> Handle(GetUiSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<bool> pagesIsDarkMode = await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.PagesIsDarkMode,
|
||||
cancellationToken);
|
||||
|
||||
Option<string> pagesLanguage = await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.PagesLanguage,
|
||||
cancellationToken);
|
||||
|
||||
return new UiSettingsViewModel
|
||||
{
|
||||
IsDarkMode = await pagesIsDarkMode.IfNoneAsync(true),
|
||||
Language = await pagesLanguage.IfNoneAsync("en")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,23 @@ public class GetXmltvSettingsHandler(IConfigElementRepository configElementRepos
|
||||
{
|
||||
public async Task<XmltvSettingsViewModel> Handle(GetXmltvSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> daysToBuild = await configElementRepository.GetValue<int>(ConfigElementKey.XmltvDaysToBuild);
|
||||
Option<int> daysToBuild = await configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.XmltvDaysToBuild,
|
||||
cancellationToken);
|
||||
|
||||
Option<XmltvTimeZone> maybeTimeZone =
|
||||
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone);
|
||||
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken);
|
||||
|
||||
Option<XmltvBlockBehavior> maybeBlockBehavior =
|
||||
await configElementRepository.GetValue<XmltvBlockBehavior>(
|
||||
ConfigElementKey.XmltvBlockBehavior,
|
||||
cancellationToken);
|
||||
|
||||
return new XmltvSettingsViewModel
|
||||
{
|
||||
DaysToBuild = await daysToBuild.IfNoneAsync(2),
|
||||
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local)
|
||||
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local),
|
||||
BlockBehavior = await maybeBlockBehavior.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UiSettingsViewModel
|
||||
{
|
||||
public bool IsDarkMode { get; set; }
|
||||
|
||||
public string Language { get; set; }
|
||||
}
|
||||
7
ErsatzTV.Application/Configuration/XmltvBlockBehavior.cs
Normal file
7
ErsatzTV.Application/Configuration/XmltvBlockBehavior.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public enum XmltvBlockBehavior
|
||||
{
|
||||
SplitTimeEvenly = 0,
|
||||
UseActualTimes = 1
|
||||
}
|
||||
@@ -4,4 +4,5 @@ public class XmltvSettingsViewModel
|
||||
{
|
||||
public int DaysToBuild { get; set; }
|
||||
public XmltvTimeZone TimeZone { get; set; }
|
||||
public XmltvBlockBehavior BlockBehavior { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
|
||||
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyCollectionScannerHandler> logger) : base(
|
||||
dbContextFactory,
|
||||
configElementRepository,
|
||||
runtimeInfo,
|
||||
logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -40,13 +49,16 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeEmbyCollections request)
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyMediaSources
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId)
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId, cancellationToken)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -64,20 +76,40 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.EmbyCollections);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-emby-collections", request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-collections",
|
||||
request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
return BaseError.New("Emby collections are already scanning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
@@ -15,14 +17,17 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyLibraryScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle(
|
||||
@@ -38,9 +43,9 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -53,37 +58,58 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-emby", request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby",
|
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeEmbyLibraryById request)
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
Option<EmbyLibrary> maybeLibrary = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
DateTime minDateTime = maybeLibrary.Match(
|
||||
l => l.LastScan ?? SystemTime.MinValueUtc,
|
||||
() => SystemTime.MaxValueUtc);
|
||||
|
||||
string libraryName = maybeLibrary.Match(l => l.Name, string.Empty);
|
||||
|
||||
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyShowById>,
|
||||
IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyShowScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyShowScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle(
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
ScanParameters parameters,
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-show",
|
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
SynchronizeEmbyShowById request) =>
|
||||
true;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
@@ -12,16 +13,19 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
|
||||
private readonly IEmbyApiClient _embyApiClient;
|
||||
private readonly IEmbySecretStore _embySecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public SaveEmbySecretsHandler(
|
||||
IEmbySecretStore embySecretStore,
|
||||
IEmbyApiClient embyApiClient,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IMemoryCache memoryCache,
|
||||
ChannelWriter<IEmbyBackgroundServiceRequest> channel)
|
||||
{
|
||||
_embySecretStore = embySecretStore;
|
||||
_embyApiClient = embyApiClient;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_memoryCache = memoryCache;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
|
||||
parameters.Secrets.Address,
|
||||
parameters.ServerInformation.ServerName,
|
||||
parameters.ServerInformation.OperatingSystem);
|
||||
_memoryCache.Remove(new GetEmbyConnectionParameters());
|
||||
await _channel.WriteAsync(new SynchronizeEmbyMediaSources());
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -2,5 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
: IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -33,18 +33,21 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
SynchronizeEmbyLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(SynchronizeLibraries)
|
||||
Validate(request, cancellationToken)
|
||||
.MapT(p => SynchronizeLibraries(p, cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeEmbyLibraries request) =>
|
||||
MediaSourceMustExist(request)
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(
|
||||
SynchronizeEmbyLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
MediaSourceMustExist(request, cancellationToken)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> MediaSourceMustExist(
|
||||
SynchronizeEmbyLibraries request) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
|
||||
SynchronizeEmbyLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Emby media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
@@ -65,7 +68,9 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
||||
.ToValidation<BaseError>("Emby media source requires an api key");
|
||||
}
|
||||
|
||||
private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters)
|
||||
private async Task<Unit> SynchronizeLibraries(
|
||||
ConnectionParameters connectionParameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, List<EmbyLibrary>> maybeLibraries = await _embyApiClient.GetLibraries(
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
@@ -91,7 +96,8 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
||||
connectionParameters.EmbyMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate);
|
||||
toUpdate,
|
||||
cancellationToken);
|
||||
if (ids.Count != 0)
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
@@ -23,7 +23,7 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
|
||||
SynchronizeEmbyMediaSources request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby();
|
||||
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby(cancellationToken);
|
||||
foreach (EmbyMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyShowById(int EmbyLibraryId, int ShowId, bool DeepScan)
|
||||
: IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest;
|
||||
@@ -23,7 +23,7 @@ public class
|
||||
UpdateEmbyLibraryPreferences request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
var toDisable = request.Preferences.Filter(p => !p.ShouldSyncItems).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisableEmbyLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -15,7 +15,7 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateEmbyPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
Validate(request, cancellationToken)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
@@ -37,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,23 @@
|
||||
<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.0.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 +26,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,34 +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,
|
||||
BitDepth = request.BitDepth,
|
||||
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
|
||||
var hwAccel = request.NormalizeVideo
|
||||
? request.HardwareAcceleration
|
||||
: HardwareAccelerationKind.None;
|
||||
|
||||
return new FFmpegProfile
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = threadCount,
|
||||
|
||||
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) =>
|
||||
@@ -79,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"));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user