Compare commits

..

286 Commits

Author SHA1 Message Date
Jason Dove
85f2c658aa update changelog for release v0.8.2-beta [no ci] 2023-09-14 09:19:21 -05:00
Jason Dove
78356314e6 update dependencies (#1433) 2023-09-14 08:46:59 -05:00
Jason Dove
b00a25bbee fix parsing show title from nfo (#1426) 2023-09-10 19:46:51 -05:00
Jason Dove
4d77576be2 update dependencies (#1425)
Co-authored-by: Jason <jason@mbp-touch.local>
2023-09-10 09:22:35 -05:00
Jason Dove
a90348740d fix subsequent hls session work ahead (#1419) 2023-09-06 20:01:58 -05:00
Jason Dove
8081845ef1 fix adding alternate schedule (#1418) 2023-09-04 14:06:39 -05:00
Jason Dove
d014eb4274 mysql ui fixes (#1417) 2023-09-04 13:12:00 -05:00
Jason Dove
8c9cf7b6f2 fix mid roll pad; fix mysql queries (#1416)
* fix mysql queries

* fix mid roll pad with hls segmenter
2023-09-04 10:01:39 -05:00
Jason Dove
5d9c8d4f7d update checkout actions (#1415) 2023-09-04 08:38:10 -05:00
Jason Dove
82de3136cd fix hls session worker lifetime (#1414) 2023-09-04 08:30:01 -05:00
Jason Dove
b1cd324f9c fix docker builds (#1413) 2023-09-03 08:54:48 -05:00
Jason Dove
5caf8f7f98 fix builds (#1412) 2023-09-03 08:52:00 -05:00
Jason Dove
245c4ec359 code analysis and cleanup (#1411)
* cleanup scanner project

* cleanup infrastructure projects

* cleanup ffmpeg project

* cleanup core project

* cleanup app project

* cleanup main project

* update dependencies

* code cleanup
2023-09-03 06:23:42 -05:00
Jason Dove
6414471ace fix emby movie libraries (#1410) 2023-09-02 13:03:48 -05:00
Jason Dove
8b0b927a5c use d3d11va for qsv accel on windows (#1408) 2023-09-01 11:50:56 -05:00
Jason Dove
e5962699a4 schedule item editor updates (#1407) 2023-09-01 06:44:31 -05:00
Jason Dove
27504e42bc fix mysql show queries (#1406)
* ignore artwork when sync is disabled

* fix delete playout

* fix some mysql queries
2023-09-01 06:05:36 -05:00
Jason Dove
deb0ac49b5 show plex server names in libraries page (#1402)
* cleanup plex libraries query

* show plex server names in libraries page
2023-08-29 08:46:36 -05:00
Jason Dove
225b95449c rework hls session state (#1401) 2023-08-29 05:59:35 -05:00
Jason Dove
cb43c28d00 fix hls session when starting with very short content (#1400) 2023-08-29 05:01:13 -05:00
Jason Dove
0a75136223 fix transcoding short content (#1399) 2023-08-28 22:01:38 -05:00
Jason Dove
efc710749e fix test 2023-08-28 20:44:35 -05:00
Jason Dove
8f241f49fc optimize transcoding speed (#1398)
* fix "empty trash" button blinking when loading trash page

* clean channel guide cache on startup

* only work-ahead in hls session for 2 minutes
2023-08-28 20:42:02 -05:00
Jason Dove
20d224fcfd more mysql fixes (#1397)
* fix channels and movie page crashes with mysql

* update dependencies
2023-08-26 10:39:33 -05:00
Jason Dove
b3fda4e88d allow shared plex servers (#1391)
* allow shared plex servers

* update dependencies
2023-08-22 08:49:26 -05:00
Jason Dove
560cb826b3 fix local subtitles display (#1388)
* show external subtitles in media info

* fix mysql saved page size
2023-08-19 11:26:52 -05:00
Jason Dove
b038a58fa2 fix saving smart collection (#1384) 2023-08-15 15:53:56 -05:00
Jason Dove
c28e201e47 fix adding shows directly to schedule (#1383) 2023-08-15 15:34:53 -05:00
Jason Dove
b84bb6b437 fix ui crashes (#1382)
* fix ffmpeg editor crash

* fix watermark editor

* fix multi collection editor

* fix filler preset editor crash
2023-08-15 06:39:29 -05:00
Jason Dove
641b8bcd10 fix windows dependency (#1380) 2023-08-14 19:44:18 -05:00
Jason Dove
4b08ed5381 fix mysql migration (initial data) (#1379) 2023-08-14 15:21:42 -05:00
Jason Dove
5486dcdcab remove docs files and workflow [no ci] 2023-08-13 20:16:49 -05:00
Jason Dove
77b32b0f09 fix github repo links (#1378) 2023-08-13 09:59:48 -05:00
Jason Dove
2c6c08becf update submodule [no ci] 2023-08-13 09:33:33 -05:00
Jason Dove
99bc19cf26 fix docker builds (#1376) 2023-08-12 22:02:45 -05:00
Jason Dove
a7661c8498 add mysql database provider (#1375)
* refactor sqlite into separate library

* support mysql

* fixes

* sql fixes

* cleanup

* update changelog
2023-08-12 21:44:14 -05:00
Jason Dove
d951035183 fix bulk removing items from elasticsearch index (#1374) 2023-08-12 08:51:12 -05:00
Jason Dove
097c60169c elasticsearch relative queries (#1373)
* remove unused code

* fix relative queries with elasticsearch

* fix some double page loads

* simplify language model
2023-08-12 06:36:28 -05:00
Jason Dove
d64d8b0454 don't always rebuild elasticsearch index on startup (#1372) 2023-08-11 15:47:17 -05:00
Jason Dove
7486304ed9 fix elasticsearch smart collection playouts (#1371) 2023-08-11 15:28:57 -05:00
Jason Dove
c62cc98c9f add elasticsearch search index provider (#1370)
* wip

* first pass at elasticsearch; movies kind of work

* use field name constants

* properly sort search results

* fix some crashes

* fix page map/jump letters

* optimize page map using terms aggregation

* index all item types

* optionally use elastic search

* code cleanup

* automatically rebuild lucene index after improper shutdown

* update changelog
2023-08-11 13:57:50 -05:00
Jason Dove
22a13cb1b3 Revert "add debug logs for other video folder scanning (#1369)"
This reverts commit 5e573461f3.
2023-08-10 12:51:47 -05:00
Jason Dove
5e573461f3 add debug logs for other video folder scanning (#1369) 2023-08-10 11:26:48 -05:00
Jason Dove
76c596a7d8 fix logs page (#1368)
* fix log viewer crash

* update dependencies
2023-08-10 10:45:43 -05:00
Jason Dove
f945f16d97 fix qsv subtitle scaling (#1367) 2023-08-10 10:16:10 -05:00
Jason Dove
797d4005e2 replace moq with nsubstitute (#1365) 2023-08-09 20:14:18 -05:00
Jason Dove
55903430ae update changelog for release v0.8.1-beta [no ci] 2023-08-07 13:32:07 -05:00
Jason Dove
f929dc92d1 update dependencies; code cleanup (#1357)
* update dependencies

* code cleanup
2023-08-07 09:34:25 -05:00
Jason Dove
2ad27c2be0 update dependencies (#1348)
* update dependencies

* silence mudblazor debug logs
2023-07-24 20:40:32 -05:00
Jason Dove
df2db5caf7 add plex file name logging (#1342) 2023-07-12 19:27:16 -05:00
Jason Dove
5978e8ecb1 fix vaapi rate control mode (#1340) 2023-07-08 12:36:07 -05:00
Jason Dove
a540efc2e1 add community to readme [no ci] 2023-07-03 13:07:42 -05:00
Jason Dove
1938cef6ae add community link to docs (#1339) 2023-07-03 13:01:54 -05:00
Jason Dove
b23d798aff update dependencies (#1329) 2023-06-26 11:11:40 -05:00
Jason Dove
ebad7664b0 force hw accel to use one thread (#1327) 2023-06-25 09:56:58 -05:00
Jason Dove
a9c93ff498 add custom resolution management (#1326)
* update some dependencies

* add custom resolution management
2023-06-25 09:14:19 -05:00
Jason Dove
8277894f7b show database and search index initialization in ui (#1325)
* unblock startup, show database initialization message

* wait on search index to be ready (rebuild)

* clean logging and fake delay
2023-06-24 09:12:46 -05:00
Jason Dove
0d66f752b6 add global mutex to ensure single instance (#1324) 2023-06-24 06:30:55 -05:00
Jason Dove
c128f72a54 update changelog for release v0.8.0 [no ci] 2023-06-23 22:16:26 -05:00
Jason Dove
4af2d7aa61 don't trust emby's anamorphic flag (#1321) 2023-06-22 20:07:58 -05:00
Jason Dove
20a6727158 fix vaapi hw decoding (#1320) 2023-06-22 15:05:02 -05:00
Jason Dove
52e1874426 vaapi cqp rate control mode (#1319) 2023-06-22 11:42:11 -05:00
Jason Dove
015f5e9798 fix playout build loop that was recently introduced (#1318) 2023-06-22 09:40:20 -05:00
Jason Dove
1fc461e476 update dapper (#1316) 2023-06-21 15:51:35 -05:00
Jason Dove
85792f0811 fix nvidia color normalization (#1314) 2023-06-20 09:23:41 -05:00
Jason Dove
0f91a43e3f fix scaling subtitles with nvidia accel (#1313) 2023-06-20 06:25:15 -05:00
Jason Dove
7a25996ab4 scale subtitles with all accels (#1311)
* properly scale subtitles with qsv and vaapi

* fixes
2023-06-19 15:55:23 -05:00
Jason Dove
6985826072 add mpeg-ts output format for hls direct (#1310) 2023-06-19 11:19:19 -05:00
Jason Dove
52482ef2fb only discard items with random or shuffle playback order (#1309) 2023-06-19 09:17:10 -05:00
Jason Dove
c148f2eb11 fix discard to fill calculation (#1308) 2023-06-17 05:11:08 -05:00
Jason Dove
d490cc6f4b dont give up on scheduling filler while some should fit (#1306) 2023-06-14 16:58:37 -05:00
Jason Dove
99bd827bd9 fix multi episode shuffle (#1305) 2023-06-14 16:40:18 -05:00
Jason Dove
e8cbcc935f rework pad and duration filler (#1304) 2023-06-14 15:54:41 -05:00
Jason Dove
a2acfe4d80 add finish column to playout detail table (#1302) 2023-06-13 19:09:19 -05:00
Jason Dove
5da2bdbab4 add duration discard to fill attempts (#1301) 2023-06-13 17:02:31 -05:00
Jason Dove
66607b95bb update dependencies (#1300) 2023-06-13 13:58:44 -05:00
Jason Dove
81a6251f65 properly lock playout before build (#1299) 2023-06-13 13:45:00 -05:00
Jason Dove
c554d83d60 playout management ui improvements (#1298) 2023-06-13 13:26:34 -05:00
Jason Dove
875010bbf4 update changelog for release v0.7.9-beta [no ci] 2023-06-10 10:40:05 -05:00
Jason Dove
c5692ef5f1 update dependencies (#1296) 2023-06-08 09:20:06 -05:00
Jason Dove
147ab6143d hls direct mkv container (#1292)
* use mkv container for hls direct

* add setting for mp4/mkv container with hls direct

* cleanup

* update changelog
2023-06-06 10:21:09 -05:00
Jason Dove
aca441074e subtitle improvements with hls direct (#1290)
* wip: hls direct subtitles

* convert picture subtitles with hls direct

* use mp4 for hls direct to support more codecs

* disable subtitle conversion in hls direct

* fix tests

* update changelog
2023-06-04 12:29:47 -05:00
Jason Dove
ef6adf9cbb update dependencies (#1289) 2023-06-02 06:35:31 -05:00
Jason Dove
ddb7e1887f fix nvidia h264 decoder (#1281) 2023-05-22 21:26:48 -05:00
Jason Dove
4997699b4d sync jf and emby episode actors (#1280) 2023-05-22 15:58:08 -05:00
Jason Dove
c27b906cd5 update docs (#1277)
* tweak mkdocs config; update install

* path replacement doc updates
2023-05-21 10:43:48 -05:00
Jason Dove
bec3cb864d update dependencies (#1276)
* update dependencies

* fix ide warnings

* tweak ef config
2023-05-21 10:13:44 -05:00
Jason Dove
03df2a6c8a overdue code cleanup (#1271) 2023-05-10 13:18:18 -05:00
dependabot[bot]
6142dcf153 Bump jetbrains.resharper.globaltools from 2022.1.0 to 2023.1.1 (#1264)
Bumps jetbrains.resharper.globaltools from 2022.1.0 to 2023.1.1.

---
updated-dependencies:
- dependency-name: jetbrains.resharper.globaltools
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-10 08:56:08 -05:00
Jason Dove
b287f791e6 fix pgs subtitle burn in from media servers (#1270) 2023-05-09 22:43:25 -05:00
Jason Dove
2ccba9e476 timeout playout builds after 2 minutes (#1269)
* add cancellation token support to playout builds and collection enumerators

* fix playout bug with shuffle in order

* update changelog
2023-05-08 11:53:02 -05:00
Jason Dove
e215807e56 add worker service debug logs (#1267)
* add worker service debug logs

* update mudblazor
2023-05-05 08:42:33 -05:00
Jason Dove
b0333e89cd fix fallback filler (#1265) 2023-05-03 12:08:14 -05:00
Jason Dove
bc240a40e0 fix extracting text subtitles (#1262) 2023-04-29 21:46:20 -05:00
Jason Dove
ed816e4b06 update changelog for release [no ci] 2023-04-29 20:48:24 -05:00
Jason Dove
7c0f26ed3e update dependencies (#1261) 2023-04-29 09:42:06 -05:00
Jason Dove
a54e37a648 proper fix 2023-04-26 19:35:40 -05:00
Jason Dove
c0656114b8 ignore dot underscore files (#1259) 2023-04-26 11:46:38 -05:00
Jason Dove
7628ec7921 fix vobsub subtitle burn in from media server libraries (#1258) 2023-04-25 15:18:53 -05:00
Jason Dove
30850329f3 fix external subtitle playback on windows (#1256) 2023-04-23 09:53:57 -05:00
Jason Dove
6bb1c4299f fix colorspace filter for some files with invalid color metadata (#1254) 2023-04-20 11:59:31 -05:00
Jason Dove
73c6758537 add show media info button to movie and episode detail pages (#1253) 2023-04-20 10:45:32 -05:00
Ministorm3
ef1400d3f8 Corrected issue with channel delete (#1247)
* Channel delete now updates the guide cache.

* update changelog

---------

Co-authored-by: Jason Dove <jason@jasondove.me>
2023-04-14 19:37:46 -05:00
Jason Dove
494142f026 fix media server text subtitle extraction (#1246) 2023-04-14 15:09:12 -05:00
Jason Dove
b89deffda3 cleanup delete channel handler syntax (#1245) 2023-04-14 12:53:33 -05:00
Jason Dove
ab55978732 add season, episode playback order for shows (#1243) 2023-04-13 11:06:48 -05:00
Jason Dove
b8dcd26e3a fix hls direct regression (#1242) 2023-04-12 15:47:25 -05:00
Jason Dove
24f7544c9f improve schedule editor performance (#1240)
* rename

* async cleanup

* use autocomplete fields in schedule items editor
2023-04-11 14:16:39 -05:00
Jason Dove
afb2caa95d fix qsv watermark alpha (#1239) 2023-04-11 10:30:19 -05:00
Jason Dove
a684dcced9 scheduling bug fixes (#1238) 2023-04-11 09:22:51 -05:00
Jason Dove
cf1552910a limit hls direct streams to realtime speed (#1237) 2023-04-09 20:21:03 -05:00
Jason Dove
e53e2b36cf update copyright in docs 2023-04-07 21:42:49 -05:00
Jason Dove
793e85f889 update changelog for release v0.7.7-beta [no ci] 2023-04-07 21:41:50 -05:00
Jason Dove
126304bb8a fix subtitles from media server libraries (#1233)
* fix embedded subtitles from media servers

* fix plex external subtitles

* fix artwork bug, delete orphaned subtitles

* jellyfin subtitles work again

* emby subtitles work

* rescan all media server libraries
2023-04-07 13:22:45 -05:00
Jason Dove
7b3b9b4aad fix search index bug (#1231) 2023-04-05 21:52:31 -05:00
Jason Dove
cf3b8d90e3 fix bt470bg color normalization using qsv (#1230) 2023-04-05 10:02:46 -05:00
Jason Dove
109b244676 use software decoding for mpeg4 part2 with nvidia accel (#1229) 2023-04-05 08:48:58 -05:00
Jason Dove
9f42333465 cache data for xmltv (#1228)
* cache channel list for xmltv

* used cached channel data for xmltv

* fixes

* update changelog
2023-04-03 23:09:54 -05:00
Jason Dove
c9141b0d86 fix colorspace filter when using vaapi (#1226) 2023-04-02 10:09:05 -05:00
Jason Dove
d2c4a58528 minor bug fixes (#1225) 2023-04-01 22:51:48 -05:00
Jason Dove
e93d678b97 add more logging (#1224)
* more logging

* update dependencies
2023-04-01 12:24:15 -05:00
Jason Dove
307940d732 add duplicate file logging (#1223) 2023-04-01 09:54:19 -05:00
Jason Dove
721f0df82a fix library scanning with non-english season folder names (#1222) 2023-03-31 13:37:54 -05:00
Jason Dove
aa87abc53d prioritize poster artwork for xmltv (#1221) 2023-03-29 08:18:03 -05:00
Jason Dove
83d4aa0cb1 use other video plot as xmltv description (#1219) 2023-03-27 19:22:44 -05:00
Jason Dove
46034aff54 fix updating trakt lists (#1218) 2023-03-25 05:58:41 -05:00
Jason Dove
3e447ac7e4 fix changelog links [no ci] 2023-03-24 14:41:10 -05:00
Jason Dove
bda27faaa3 update changelog for release v0.7.6-beta [no ci] 2023-03-24 14:40:04 -05:00
Jason Dove
80d89a2530 fix color normalization from bt470bg to bt709 (#1217) 2023-03-22 08:04:21 -05:00
Jason Dove
e849ef5dfa remove bad file 2023-03-21 22:35:09 -05:00
Jason Dove
a26ecb91b8 ignore sidecar subtitle files from media server libraries (#1216) 2023-03-21 20:44:13 -05:00
Jason Dove
2853e13edc update changelog [no ci] 2023-03-17 10:05:28 -05:00
Jason Dove
9ba0b844a1 JWT Query Parameter Auth for IPTV Links (#1215)
* JWT Auth

* Standardized url variable additions

* formatting and minor refactoring

* this isn't needed

* allow channel logos without auth

* update changelog

---------

Co-authored-by: Ministorm3 <4474921+Ministorm3@users.noreply.github.com>
2023-03-17 09:33:15 -05:00
Jason Dove
fdab54a055 limit console log output on windows (#1212) 2023-03-16 06:21:02 -05:00
Jason Dove
7e0801119e maintain collection progress across alternate schedules (#1211) 2023-03-15 20:34:25 -05:00
Jason Dove
b2f7bcaf1e add more fields to music video credit templates (#1210) 2023-03-15 20:10:24 -05:00
Jason Dove
71b8be37da restyle troubleshooting page (#1208) 2023-03-13 13:24:27 -05:00
Jason Dove
f7d19e3747 duration mode guide fixes (#1207)
* fix playout mode duration xmltv

* fix playout mode duration wrapping midnight
2023-03-13 09:00:59 -05:00
Jason Dove
17dcbfc344 add troubleshooting page (#1206) 2023-03-12 13:00:54 -05:00
Jason Dove
78745de0ca rework emby collection scanning (#1205)
* optimize emby collection scan frequency

* add button to sync emby collections

* update changelog

* fix scanning; add progress indicator
2023-03-11 22:29:10 -06:00
Jason Dove
35445e2b3d proxy external subtitle files (#1203) 2023-03-09 19:31:59 -06:00
Jason Dove
bd2f0f6236 song normalization (#1202)
* add tests to verify song normalization

* simplify song setup, include watermarks and album art

* fix song path

* update changelog
2023-03-09 10:40:59 -06:00
Jason Dove
4c67965b50 fix emby scanning (#1201) 2023-03-09 07:59:26 -06:00
Jason Dove
234e93349b rework concurrency (#1199) 2023-03-08 21:23:18 -06:00
Jason Dove
e7e20de502 include multiple display-name entries in xmltv (#1198) 2023-03-07 09:53:00 -06:00
Jason Dove
dfc36b4581 case-insensitive file extensions in local scanner (#1197) 2023-03-06 18:21:34 -06:00
Jason Dove
c56e2526c4 fix media server scanning (#1196)
* fix media server scans

* update dependencies
2023-03-06 08:28:35 -06:00
Jason Dove
8ff6bf652c fix jellyfin streaming and sar calculation (#1195) 2023-03-05 21:40:20 -06:00
Jason Dove
a386fe9ba1 update changelog for release v0.7.5-beta [no ci] 2023-03-05 10:12:21 -06:00
Jason Dove
4d84fc242b plex scanner improvement (#1193)
* fix crash with some plex multi-episode files

* comments cleanup
2023-03-03 06:08:42 -06:00
Jason Dove
40e79a3a14 fix plex scanner crash (#1192) 2023-03-02 22:49:21 -06:00
Jason Dove
c653bb32a7 plex scanner logging 2023-03-02 20:57:38 -06:00
Jason Dove
b032e70d7e support more local season folder names (#1191) 2023-03-02 20:13:34 -06:00
Jason Dove
074816be50 simplify qsv accel syntax on linux (#1189) 2023-03-02 06:00:36 -06:00
Jason Dove
3fafd5192f fix hevc_nvenc encoder on sm < 75 (#1187) 2023-03-01 20:17:58 -06:00
Jason Dove
1d63197b56 fix yuv444p10le (#1186) 2023-03-01 19:56:14 -06:00
Jason Dove
b2c57e7407 upgrade to ffmpeg 6 (#1185) 2023-03-01 19:21:53 -06:00
Jason Dove
581aa51792 fix trash display for certain episodes (#1184)
* fix trash display for certain episodes

* fix multi-episode fallback metadata
2023-02-28 09:37:30 -06:00
Jason Dove
4d57ece30d check ffmpeg for available decoders, filters, encoders (#1183)
* check ffmpeg for available decoders, filters, encoders

* revert csproj change
2023-02-27 19:28:42 -06:00
Jason Dove
eddbf07b11 vaapi: decode vp9 and av1 (#1181) 2023-02-27 05:57:13 -06:00
Jason Dove
450ea063b4 update vaapi docker bundled ffmpeg version 2023-02-26 21:39:22 -06:00
Jason Dove
f320d84874 fix ffmpeg version health check for vaapi docker (#1179) 2023-02-25 08:18:49 -06:00
Jason Dove
c832c8e860 prioritize default audio tracks (#1178) 2023-02-24 15:45:30 -06:00
Jason Dove
e5ef8eaf72 fix some cases where vaapi hwdownload would fail; use libvpl in docker (#1177)
* fix some cases where vaapi hwdownload would fail

* update changelog
2023-02-24 09:10:42 -06:00
Jason Dove
6db71f525d remove duplicate filter from search index (#1172) 2023-02-21 22:15:57 -06:00
Jason Dove
3ab66ef12a update intel media driver in vaapi docker image 2023-02-21 19:02:54 -06:00
Jason Dove
018f759fa4 improve vaapi capability detection (#1171) 2023-02-21 09:53:23 -06:00
Jason Dove
1afff11063 software decoder fixes (#1169)
* fix software decoder pipeline bugs

* tweak nvidia scaling logic

* update changelog

* update dependencies
2023-02-20 19:45:25 -06:00
Jason Dove
7e3436e68f direct stream content from emby as needed (#1168) 2023-02-19 20:29:09 -06:00
Jason Dove
b751f1054b direct stream content from jellyfin if needed (#1167)
* redirect to jellyfin stream as needed

* get jellyfin playback info

* sync chapters from jellyfin

* update changelog

* cleanup
2023-02-19 10:37:48 -06:00
Jason Dove
900e9e75f3 sync chapter markers from plex (#1166) 2023-02-18 14:51:51 -06:00
Jason Dove
62c28d9f51 direct stream content from plex if needed (#1165)
* start to stream directly from plex

* update metadata and statistics with one plex api call

* stream movies from plex

* scanning bug fix; update changelog
2023-02-18 10:40:05 -06:00
Jason Dove
132ca99f94 fix default dockerfile (#1156) 2023-02-13 05:38:11 -06:00
Jason Dove
c309ab430e update changelog for release v0.7.4-beta [no ci] 2023-02-12 18:21:11 -06:00
Jason Dove
13e21bbcce sync episode tags and genres (#1155)
* sync episode tags and genres

* update dependencies

* property update local episode genres and tags

* fix test
2023-02-12 09:53:20 -06:00
Jason Dove
0eb36f0ce1 prioritize default audio streams (#1154) 2023-02-10 09:31:55 -06:00
Jason Dove
6429f0f064 fix filler padding (#1153)
* fix filler padding

* update dependencies
2023-02-07 19:55:30 -06:00
Jason Dove
7412ac6fc9 fix mid and post roll filler ordering (#1152) 2023-02-07 12:25:22 -06:00
Jason Dove
e58e3c786d fix last scan check (#1150) 2023-02-06 05:38:08 -06:00
Jason Dove
93fc1e4eb4 fix fallback filler looping (#1146) 2023-02-04 08:49:52 -06:00
Jason Dove
cacde26796 merge other video folder tags with nfo tags (#1144) 2023-02-01 05:58:26 -06:00
Jason Dove
0a3db92c60 fix schedule copy (#1142) 2023-01-30 10:23:18 -06:00
Jason Dove
8bb0cd5ab5 add copy schedule feature (#1141) 2023-01-30 06:38:34 -06:00
Jason Dove
e497dc4e36 fix nvidia vp9 color normalization (#1140) 2023-01-29 16:02:29 -06:00
Jason Dove
2689a67eb8 qsv and vaapi fixes (#1139)
* lots of qsv fixes

* update changelog

* fix qsv mpeg2

* vaapi fixes

* update changelog

* upgrade mudblazor

* fix bug with undefined input colorspace
2023-01-29 10:00:52 -06:00
Jason Dove
3d821043bb update changelog for v0.7.3-beta [no ci] 2023-01-25 12:01:38 -06:00
Jason Dove
e69c58e615 conditionally disable v2 apis (#1135)
* conditionally disable v2 apis

* update changelog
2023-01-25 11:37:43 -06:00
Jason Dove
a21b6f9f4e add oidc logout url to support auth0 (#1134) 2023-01-25 09:30:36 -06:00
Jason Dove
99b8038852 add oidc support (#1133) 2023-01-25 08:37:59 -06:00
Jason Dove
ef8ca9f8c6 build mac artifacts on macos 11 (#1132) 2023-01-24 15:04:55 -06:00
Jason Dove
d9186df157 minor logging and doc updates (#1130) 2023-01-23 05:28:17 -06:00
Jason Dove
aca6bfb0bb fix multiple gcs after extracting subtitles (#1129) 2023-01-22 13:10:13 -06:00
Jason Dove
587fc3a98f release memory after extracting embedded subtitles (#1128) 2023-01-22 12:34:42 -06:00
Jason Dove
ab1c67e60e memory improvements (#1127)
* regularly release memory

* don't aggressively GC while legacy streaming

* update changelog
2023-01-22 09:16:24 -06:00
Jason Dove
e271f43066 more scan check fixes (#1126) 2023-01-21 08:22:56 -06:00
Jason Dove
6bf8feb26e fix local library scan check with new install (#1125) 2023-01-21 08:10:42 -06:00
Jason Dove
ffd66f6a21 fix removing media server libraries (#1124) 2023-01-20 09:31:18 -06:00
Jason Dove
3b135df4c1 scan with below-normal priority when unforced (#1123) 2023-01-20 06:05:39 -06:00
Jason Dove
4369d04940 scanner improvements (#1122)
* optimize periodic scanning

* set scanner process priority

* update dependencies
2023-01-20 05:37:39 -06:00
Jason Dove
faaa78fed7 update changelog [no ci] 2023-01-18 15:40:00 -06:00
Jason Dove
6bea1660ea disable mac compression; this is needed until dotnet 7.0.3 (#1120) 2023-01-18 15:13:07 -06:00
Jason Dove
8d46676c25 try to fix mac scanning (#1119) 2023-01-18 14:43:26 -06:00
Jason Dove
4c75e638a2 fix bug with smart collection progress (#1118) 2023-01-18 14:09:54 -06:00
Jason Dove
dd73a3803a fix schedule editor crash (#1115)
* fix schedule editor crash due to bad music video artist data

* update dependencies
2023-01-15 06:35:51 -06:00
Jason Dove
f6c345d7cf fix build 2023-01-10 15:13:42 -06:00
Jason Dove
585b56a668 bug fixes (#1107)
* don't search an empty search index

* fix bug with flood filler prediction check

* extract subtitles on primary worker thread
2023-01-10 14:45:04 -06:00
Jason Dove
f18f3b4f35 try to fix develop artifacts 2023-01-09 08:46:55 -06:00
Jason Dove
eb7871a048 fix alternate schedule playout update check (#1106)
* fix alternate schedule playout update check

* Revert "use mknejp/delete-release-assets again"

This reverts commit 07ac833067.
2023-01-09 05:36:15 -06:00
Jason Dove
000fc78fd3 add alternate schedule system (#1105)
* start to add program schedule alternates

* edit days of the week

* editor improvements

* save changes

* build playouts using alternate schedules

* reset playout as needed

* add priority message
2023-01-08 23:22:17 -06:00
Jason Dove
ba676ef956 add jellyfin admin error logging (#1102) 2023-01-07 09:55:02 -06:00
Jason Dove
36ea88e2d6 fix error display (#1099)
* fix error display by ignoring hw accel setting

* update changelog

* revert background change
2023-01-05 20:02:48 -06:00
Jason Dove
5237e6fa50 update changelog for release v0.7.2-beta [no ci] 2023-01-05 11:51:57 -06:00
Jason Dove
99bde1819c use mknejp/delete-release-assets again (#1098) 2023-01-05 10:26:42 -06:00
Jason Dove
f5d7ec2890 update workflow [no ci] 2023-01-05 10:06:32 -06:00
Jason Dove
13c65435d3 update dependencies (#1097) 2023-01-05 09:27:12 -06:00
Jason Dove
315420f1a5 fix log viewer on windows (#1095)
* fix log viewer on windows

* catch cancellation on trakt page

* update changelog
2023-01-04 22:26:35 -06:00
Jason Dove
ab7051f075 reimplement log viewer (#1094) 2023-01-04 10:09:11 -06:00
Jason Dove
a43e5bbe9d update changelog for release v0.7.1-beta [no ci] 2023-01-03 09:40:59 -06:00
Jason Dove
b7bd4541b1 hide windows on windows (#1091)
* hide windows on windows

* update dependencies
2023-01-03 09:14:50 -06:00
Jason Dove
648f25e9cc fix terminating server process 2023-01-01 14:20:52 -06:00
Jason Dove
ccbe85a46a also hide the main server window 2023-01-01 14:12:41 -06:00
Jason Dove
d168d79fe0 hide windows wrapper console (#1088) 2023-01-01 13:42:39 -06:00
Jason Dove
d37dde2477 try to fix windows build 2023-01-01 13:19:12 -06:00
Jason Dove
8e13b07c84 convert windows project from dotnet to rust (#1087)
* convert windows project from dotnet to rust

* update pr jobs

* pr job fixes

* don't bother building mac app in prs for now

* build windows wrapper with rust
2023-01-01 13:01:58 -06:00
Jason Dove
927e7724f0 fix search bug (#1086)
* fix removing media items from search index

* update changelog
2023-01-01 08:49:15 -06:00
Jason Dove
6558c5bd69 fix subtitle update bug (#1085)
* fix saving some subtitles to database

* fix ffprobe regression
2022-12-31 19:57:14 -06:00
Jason Dove
5f7efbb69c properly fall back to software pipeline (#1084) 2022-12-31 14:06:00 -06:00
Jason Dove
b79795af50 add debug logging to local subtitle provider (#1083) 2022-12-31 11:36:08 -06:00
Jason Dove
9479806cb0 scanner refactoring and other cleanup (#1082)
* move subtitles provider into scanner

* move more stuff into scanner

* move nfo into scanner

* add scan subcommand

* fix a bunch of nfo build warnings

* more subcommands

* fix warnings

* cleanup logging

* remove unused code

* cleanup old ffmpeg stuff

* rename complex filter

* refactor wrapped segmenter
2022-12-31 10:57:20 -06:00
Jason Dove
6e49ea78ec music video template contrib (#1081)
* add another music video template

* add more music video credit templates
2022-12-30 13:26:07 -06:00
Jason Dove
7b1edd9c54 add new scanner process (#1080)
* start moving local scans to separate process

* send progress updates to main process

* move scanners and tests

* simplify dependencies; sync search index

* commit search index more often when scanning

* support forced scan and cancellation

* use scanner process for plex libraries

* update changelog

* update dockerfiles

* fix search index for local folder scanning

* rework plex scanners

* rework scanner handlers

* emby works again

* sync jellyfin

* cleanup

* update build

* update changelog

* remove scanner dependency in pr and artifacts workflows

* fix mac sed syntax

* fix pr build
2022-12-30 12:53:05 -06:00
Jason Dove
aeaafd2964 add scanner subtitle logging (#1079) 2022-12-29 06:00:00 -06:00
Jason Dove
622fa01602 update dependencies and fix some types (#1077) 2022-12-28 14:21:25 -06:00
Jason Dove
e2b3c1ce8e properly unflag local movies that are now present on disk (#1076) 2022-12-28 13:41:01 -06:00
Jason Dove
6c5db650e7 nvidia pixel format and song fixes (#1075)
* fix nvidia pixel format edge case

* fix nvidia song playback
2022-12-24 20:39:22 -06:00
Jason Dove
731072425b fix nvidia pipeline that only requires setparams (#1074) 2022-12-24 13:19:27 -06:00
Jason Dove
0f817308a8 limit library scan interval (#1073)
* limit library scan interval

* fix condition
2022-12-24 12:58:38 -06:00
Jason Dove
0fc1e15cac colorspace fixes; song playback fixes (#1072)
* fix colorspace bug, vaapi song playback

* more colorspace fixes, nvidia fixes

* nvidia colorspace fixes

* fix some qsv output color metadata

* update changelog

* update changelog
2022-12-23 15:11:05 -06:00
Jason Dove
acf30384b7 update changelog [no ci] 2022-12-20 20:14:31 -06:00
Jason Dove
d2040eaac9 pipeline fixes when colorspace filter is used (#1068)
* fix colorspace filter with missing input transfer or input primaries

* properly download before applying colorspace filter

* fix extra hwupload/hwdownload with nvidia pipeline

* colorspace tests

* update dependencies
2022-12-20 20:12:27 -06:00
Jason Dove
93673fce03 add more logging to vaapi capabilities detection (#1059) 2022-12-15 19:32:48 -06:00
Jason Dove
d7a432068b fix arm docker builds 2022-12-15 14:26:00 -06:00
Jason Dove
cb9215980a fix dockerfiles, focal to jammy 2022-12-15 14:14:07 -06:00
Jason Dove
a4fc1f1c6f upgrade to dotnet 7, ffmpeg 5.1.2 (#1058)
* wip

* update dockerfiles

* more net6 to net7

* update dependencies

* update builds
2022-12-15 14:08:21 -06:00
Jason Dove
cbbdb11938 update changelog for release v0.7.0-beta [no ci] 2022-12-11 06:53:05 -06:00
Jason Dove
a2274bca7b detect vaapi capabilities (#1051)
* remove unused pipeline

* spike vaapi hardware capabilities

* more vaapi capabilities

* use proper vaapi driver

* update readme

* update dependencies
2022-12-10 14:10:19 -06:00
Jason Dove
f84496b09d extract attached fonts (#1050) 2022-12-09 22:22:15 -06:00
Jason Dove
3abf310a3b add amf pipeline (#1048) 2022-12-09 15:23:00 -06:00
Jason Dove
f12e361c2e fix videotoolbox color normalization (#1047) 2022-12-08 13:11:46 -06:00
Jason Dove
cd0f1e98cc fix qsv color normalization (#1046) 2022-12-08 08:17:59 -06:00
Jason Dove
325ef80951 normalize bit depth via new pipeline (#1045)
* fix subtitles test and nvidia subtitles

* new ffmpeg pipelines; software and nvidia

* partial qsv

* fix qsv

* fix software pipeline

* add vaapi pipeline

* fix qsv 10-bit h264 output

* nvidia fixes

* properly disable 10-bit h264 hardware encoders

* more nvidia fixes

* add video toolbox pipeline
2022-12-07 21:25:55 -06:00
Jason Dove
9a30d7c7da error report bug fixes (#1042)
* fix some potential null reference exceptions

* searching isn't actually async

* add search query breadcrumb
2022-12-03 05:47:04 -06:00
Jason Dove
25ea75b761 more color fixes (#1040) 2022-11-25 21:25:04 -06:00
Jason Dove
32edf77d35 fix bt709 check (#1039) 2022-11-25 10:09:58 -06:00
Jason Dove
47fbb2b1b7 properly unlock trakt (#1035) 2022-11-23 18:34:55 -06:00
Jason Dove
e388f81e1f re-enable bugsnag auto notification (#1034) 2022-11-22 20:09:25 -06:00
Jason Dove
f0bea295c4 add video stats to search index (#1033) 2022-11-22 09:35:28 -06:00
Jason Dove
7439ded43d normalize bit depth (#1031)
* normalize bit depth and color for nvenc

* fix hls direct

* update changelog

* add bit depth option to ffmpeg profile
2022-11-21 20:20:07 -06:00
Jason Dove
6a640d3708 fix ogg song metadata (#1030) 2022-11-20 12:44:08 -06:00
Jason Dove
776bce9087 use base path in channel playlist and channel guide (#1028) 2022-11-20 08:28:14 -06:00
Jason Dove
3c499f9e97 proper fix 2022-11-19 10:41:16 -06:00
Jason Dove
114ff7a3e3 fix develop build cleanup (#1027) 2022-11-19 10:16:12 -06:00
Jason Dove
527cdf523c fix work-ahead limit setting (#1023) 2022-11-16 21:17:23 -06:00
Jason Dove
91eb8ab824 try to fix develop release 2022-11-04 11:26:05 -05:00
Jason Dove
7a87fb1c2e fix removing emby and jellyfin libraries (#1011)
* fix removing jellyfin and emby libraries

* remove unneeded change
2022-11-04 06:25:51 -05:00
Jason Dove
d8cc6b4c22 fix audio stream selection (#1010) 2022-11-02 14:55:43 -05:00
Jason Dove
c9bd94d9f8 use javascript instead of lua for external scripts; add audio stream selector scripts (#1005)
* use js instead of lua

* update dependencies

* add audio stream selector script for episodes

* add audio stream selector script for movies

* update changelog
2022-10-28 17:05:07 -05:00
Jason Dove
93bf818882 fix syntax [no ci] 2022-10-22 11:20:42 -05:00
Jason Dove
723fb3848d try to fix release by skipping unnecessary step 2022-10-22 11:19:39 -05:00
Jason Dove
6a213e2249 update changelog for release v0.6.9-beta [no ci] 2022-10-21 21:15:43 -05:00
Jason Dove
a6c5c3a317 bump search index version 2022-10-21 15:21:33 -05:00
Jason Dove
9313d2c8eb fix guide mode filler in xmltv (#1000) 2022-10-16 13:23:26 -05:00
Jason Dove
485a874ab5 fix automatic playout reset (#999) 2022-10-16 10:30:54 -05:00
Jason Dove
f2bc884632 fix x-forwarded-proto (#998) 2022-10-13 13:16:51 -05:00
Jason Dove
39d6653f8e temporarily enable http logging (#997) 2022-10-13 12:58:15 -05:00
Jason Dove
2ce0fcb264 proxy server improvements (#996) 2022-10-13 12:17:13 -05:00
Jason Dove
8bf5e18ae5 fix nfo reader (#995)
* fix nfo reader

* fix template fade

* update dependencies
2022-10-13 05:08:41 -05:00
Jason Dove
88f4d8074a fix stream_seek type (#988)
* fix stream seek type

* cleanup
2022-10-09 21:20:34 -05:00
Jason Dove
f5aa2fcac8 add multi-episode shuffle playout order (#987) 2022-10-09 10:49:07 -05:00
Jason Dove
6f892bea6b optionally place watermark within source content (#986) 2022-10-09 08:16:59 -05:00
Jason Dove
cbf0c9c988 fix transcoding tests 2022-10-08 21:41:49 -05:00
Jason Dove
393c67213d add stream_seek to music video credits template (#985) 2022-10-08 19:45:24 -05:00
Jason Dove
f69de9f071 fix all_artists in music video credits template (#984) 2022-10-08 12:40:46 -05:00
Jason Dove
2e400c0d22 simplify music video credits config (#983) 2022-10-08 07:52:57 -05:00
Jason Dove
6035c10550 add music video credits template system (#982)
* add music video credits template system

* fix search index bug

* cleanup csproj
2022-10-07 21:02:29 -05:00
Jason Dove
555b156154 fix tail and fallback filler scheduling (#981) 2022-10-07 09:21:19 -05:00
1484 changed files with 127944 additions and 15152 deletions

View File

@@ -3,16 +3,10 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2022.1.0",
"version": "2023.2.0",
"commands": [
"jb"
]
},
"swashbuckle.aspnetcore.cli": {
"version": "5.6.2",
"commands": [
"swagger"
]
}
}
}

View File

@@ -83,3 +83,8 @@ tab_width=4
[*.yml]
indent_style = space
indent_size = 2
[*.cs]
# disable CA1848: Use the LoggerMessage delegates`
dotnet_diagnostic.ca1848.severity = none

View File

@@ -33,23 +33,23 @@ jobs:
strategy:
matrix:
include:
- os: macos-latest
- os: macos-11
kind: macOS
target: osx-x64
- os: macos-latest
- os: macos-11
kind: macOS
target: osx-arm64
steps:
- name: Get the sources
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v2
uses: actions/setup-dotnet@v3
with:
dotnet-version: 6.0.x
dotnet-version: 7.0.x
- name: Setup Node.js
uses: actions/setup-node@v3
@@ -57,7 +57,7 @@ jobs:
node-version: '14'
- name: Cache NPM dependencies
uses: bahmutov/npm-install@v1.4.5
uses: bahmutov/npm-install@v1.8.28
with:
working-directory: ErsatzTV/client-app
@@ -81,7 +81,10 @@ jobs:
- name: Build
shell: bash
run: dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
run: |
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Bundle
shell: bash
@@ -131,6 +134,7 @@ jobs:
- 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 }}
@@ -168,22 +172,28 @@ jobs:
target: win-x64
steps:
- name: Get the sources
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v2
uses: actions/setup-dotnet@v3
with:
dotnet-version: 6.0.x
dotnet-version: 7.0.x
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '14'
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
if: ${{ matrix.kind == 'windows' }}
- name: Cache NPM dependencies
uses: bahmutov/npm-install@v1.4.5
uses: bahmutov/npm-install@v1.8.28
with:
working-directory: ErsatzTV/client-app
@@ -193,12 +203,12 @@ jobs:
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target }}"
- uses: suisei-cn/actions-download-file@v1
- uses: suisei-cn/actions-download-file@v1.3.0
if: ${{ matrix.kind == 'windows' }}
id: downloadffmpeg
name: Download ffmpeg
with:
url: "https://github.com/GyanD/codexffmpeg/releases/download/5.1/ffmpeg-5.1-full_build.7z"
url: "https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.7z"
target: ffmpeg/
- name: Build
@@ -209,11 +219,15 @@ jobs:
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
# Build Windows launcher
if [ "${{ matrix.kind }}" == "windows" ]; then
dotnet publish ErsatzTV-Windows/ErsatzTV-Windows.csproj --framework net6.0-windows --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
ls -l ErsatzTV-Windows/target/release
mv ErsatzTV-Windows/target/release/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
fi
# Download ffmpeg
@@ -237,6 +251,7 @@ jobs:
- 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 }}

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Docker Tag
@@ -34,7 +34,7 @@ jobs:
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
info_version: ${{ env.INFO_VERSION }}
build_and_upload:
uses: jasongdove/ersatztv/.github/workflows/artifacts.yml@main
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
needs: calculate_version
with:
release_tag: develop
@@ -47,7 +47,7 @@ jobs:
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
build_and_push:
uses: jasongdove/ersatztv/.github/workflows/docker.yml@main
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
with:
base_version: develop

View File

@@ -49,7 +49,7 @@ jobs:
qemu: true
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0

View File

@@ -1,21 +0,0 @@
name: Publish docs via GitHub Pages
on:
push:
branches:
- main
paths:
- docs/**
- mkdocs.yml
jobs:
build:
name: Deploy docs
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v3
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CUSTOM_DOMAIN: ersatztv.org

View File

@@ -2,20 +2,21 @@
on:
pull_request:
jobs:
build_and_test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ windows-latest, ubuntu-latest, macos-latest ]
build_and_test_windows:
runs-on: windows-latest
steps:
- name: Get the sources
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup .NET Core
uses: actions/setup-dotnet@v2
uses: actions/setup-dotnet@v3
with:
dotnet-version: 6.0.x
dotnet-version: 7.0.x
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -23,6 +24,67 @@ jobs:
- name: Install dependencies
run: dotnet restore
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --no-restore --verbosity normal
- name: Build Windows
run: |
cd ErsatzTV-Windows
cargo build --release --all-features
build_and_test_linux:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Setup .NET Core
uses: actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --no-restore --verbosity normal
build_and_test_mac:
runs-on: macos-11
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v3
with:
dotnet-version: 7.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

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Docker Tag
@@ -29,7 +29,7 @@ jobs:
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
info_version: ${{ env.INFO_VERSION }}
build_and_upload:
uses: jasongdove/ersatztv/.github/workflows/artifacts.yml@main
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
needs: calculate_version
with:
release_tag: ${{ needs.calculate_version.outputs.artifacts_version }}
@@ -42,7 +42,7 @@ jobs:
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
build_and_push:
uses: jasongdove/ersatztv/.github/workflows/docker.yml@main
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
with:
base_version: latest

View File

@@ -7,12 +7,12 @@ jobs:
steps:
# Checkout the current repo
- name: Checkout current repository
uses: actions/checkout@v3
# Setup NodeJS version 14
- name: Setup NodeJS V14.x.x
uses: actions/checkout@v4
# Setup NodeJS version 16
- name: Setup NodeJS V16.x.x
uses: actions/setup-node@v3
with:
node-version: '14'
node-version: '16'
# CD into the current client directory and lint and build the client
- name: Lint and Build the client
run: |

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "ErsatzTV-macOS"]
path = ErsatzTV-macOS
url = git@github.com:jasongdove/ErsatzTV-macOS.git
url = git@github.com:ErsatzTV/ErsatzTV-macOS.git

View File

@@ -5,6 +5,410 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.8.2-beta] - 2023-09-14
### Added
- Automatically rebuild search index after improper shutdown
- Add *experimental* support for Elasticsearch as search index backend
- No query changes should be needed since ES is backed by lucene and supports the same query syntax
- This can be configured using the following env vars (note the double underscore separator `__`)
- `ELASTICSEARCH__URI` (e.g. `http://localhost:9200`)
- `ELASTICSEARCH__INDEXNAME` (default is `ersatztv`)
- Add *experimental* support for MySQL/MariaDB database provider
- ***There is no functionality to migrate data between providers***
- This can be configured using the following env vars (note the double underscore separator `__`)
- `PROVIDER` - set to `MySql`
- `MYSQL__CONNECTIONSTRING` - (e.g. `Server=localhost;Database=ErsatzTV;Uid=root;Pwd=ersatztv;`)
- Add option to use shared Plex servers, not just owned servers
- This can be enabled by setting the env var `ETV_ALLOW_SHARED_PLEX_SERVERS` to any non-empty value
- Show Plex server names in Libraries page
### Fixed
- Fix subtitle scaling when using QSV hardware acceleration
- Fix log viewer crash when log file contains invalid data
- Clean channel guide cache on startup (delete channels that no longer exist)
- Fix Emby movie libraries so local file access is not required
- Fix adding alternate schedule
- Fix parsing show title from NFO file that also contains season information
### Changed
- Optimize transcoding session to only work ahead (at max speed) for 3 minutes before throttling to realtime
- This should *greatly* reduce cpu/gpu use when joining a channel, particularly with long content
- Allow manually editing (typing) schedule item fixed start time
- Use different control for editing schedule item duration, and allow 24-hour duration
- This is needed if you want a default/fallback alternate schedule to fill the entire day with one schedule item
- The schedule item should have a fixed start time of midnight (00:00) and a duration of 24 hours
- Use Direct3D 11 for QSV acceleration on Windows
## [0.8.1-beta] - 2023-08-07
### Added
- Add custom resolution management to `Settings` page
### Fixed
- Only allow a single instance of ErsatzTV to run
- This fixes some cases where the search index would become unusable
- Fix VAAPI rate control mode capability check
### Changed
- Rework startup process to show UI as early as possible
- A minimal UI will indicate when the database and search index are initializing
- The UI will automatically refresh when the initialization processes have completed
- Force ffmpeg to use one thread when hardware acceleration is used since hardware acceleration does not support multiple threads
## [0.8.0-beta] - 2023-06-23
### Added
- Disable playout buttons and show spinning indicator when a playout is being modified (built/extended, or subtitles are being extracted)
- Automatically reload playout details table when playout build is complete
- Add `Discard To Fill Attempts` setting to duration playout mode
- This setting only has an effect when it's configured to be greater than zero and when using `Shuffle` or `Random` playback order
- When the current item is longer than the remaining duration, it will be discarded and ETV will try to fit the next item in the collection, up to the configured number of times
- When the remaining duration is shorter than all items in the collection, the normal filler logic will be used
- Add `Finish` column to playout detail table
### Fixed
- Skip checking for subtitles to extract when subtitles are not enabled on a channel/schedule item
- Properly scale subtitles when using hardware acceleration
- Fix color normalization of content with missing color metadata when using NVIDIA acceleration
- `VAAPI`: explicitly use `CQP` rate control mode when it's the only compatible mode
- Fix scaling anamorphic Emby content that Emby claims is not anamorphic
### Changed
- `HLS Direct` streaming mode
- Use `MPEG-TS` container/output format by default to maintain v0.7.8 compatibility
- `MP4` and `MKV` container/output format can still be configured in `Settings`
- Improve `MP4` compatibility with certain content
- For `Pad` and `Duration` filler - prioritize filling the configured pad/duration
- This will skip filler that is too long in an attempt to avoid unscheduled time
- You may see the same filler more often, which means you may want to add more filler to your library so ETV has more options
- Update ffmpeg, libraries and drivers in all docker images
## [0.7.9-beta] - 2023-06-10
### Added
- Synchronize actor metadata from Jellyfin and Emby television libraries
- New libraries and new episodes will get actor data automatically
- Existing libraries can deep scan (one time) to retrieve actor data for existing episodes
- `HLS Direct` streaming mode
- Use `MP4` container/output format by default, with new global option to use `MKV` container/output format
- `MP4` output format: stream copy dvd subtitles
- `MKV` output format: stream copy any embedded subtitles
### Fixed
- Fix extracting embedded text subtitles that had been incompletely extracted in the past
- Fix fallback filler looping by forcing software mode for this content
- Other content will still use hardware acceleration as configured
- Hardware-accelerated fallback filler may be re-enabled in the future
- Fix playout building when shuffle in order is used with a single media item
- Fix pgs subtitle burn in from media server libraries
- Fix subtitle and watermark overlays with RadeonSI VAAPI driver
- Fix NVIDIA pipeline to use hardware-accelerated decoder with 8-bit h264 content
### Changed
- Timeout playout builds after 2 minutes; this should prevent playout bugs from blocking other functionality
## [0.7.8-beta] - 2023-04-29
### Added
- Add `Season, Episode` playback order
- This is currently *only* available when a show is added directly to a schedule
- This will ignore release date and sort exclusively by season number and then by episode number
- Add `Show Media Info` button to movie and episode detail pages for troubleshooting
### Fixed
- Limit `HLS Direct` streams to realtime speed
- Fix `Reset Playout` button to use worker thread instead of UI thread
- This fixes potential UI hangs and database concurrency bugs
- Maintain watermark alpha channel (built-in transparency) using QSV acceleration
- Properly extract and burn in embedded text subtitles using Jellyfin, Emby and Plex libraries
- Fix bug where deleting a channel would not remove its data from XMLTV
- Fix colorspace filter for some files with invalid color metadata
- Fix playback of external subtitles on Windows
- Fix vobsub subtitle burn in from media server libraries
### Changed
- Remove duplicate items from smart collections before scheduling
- i.e. shows no longer need to be filtered out if search results also include episodes
- Certain multi-collection scenarios may still include duplicates across multiple collections
- Use autocomplete fields for collection searching in schedule items editor
- This greatly improves the editor performance
- Ignore dot underscore files
## [0.7.7-beta] - 2023-04-07
### Added
- Use `plot` field from Other Video NFO metadata as XMLTV description
- Add detailed warning log when a file is added to ErsatzTV more than once
### Fixed
- Fix updating (re-adding) Trakt lists to properly use new metadata ids that were not present when originally added
- Fix local show library scanning with non-english season folder names, e.g. `Staffel 02`
- Fix bug where local libraries would merge with media server libraries when the same file was added to both libraries
- Fix transcoding some 10-bit content from media servers using VAAPI acceleration
- Fix decoding of MPEG-4 Part 2 (e.g. DivX) content using NVIDIA acceleration
- Fix color normalization from `bt470bg` to `bt709` using QSV acceleration
- Fix adding files to search index with unknown video codec
- Fix subtitle burn-in (embedded or external) using Jellyfin, Emby and Plex libraries
- **This requires a one-time full library scan, which may take a long time with large libraries.**
### Changed
- Use Poster artwork for XMLTV if available
- If Poster artwork is unavailable, use Thumbnail
- Improve XMLTV response time by caching data as playouts are updated
## [0.7.6-beta] - 2023-03-24
### Added
- Add `Troubleshooting` page with aggregated settings/hardware accel info for easy reference
- Read `director` fields from music video NFO metadata
- Pass `directors` and `studios` to music video credit templates
- Add optional JSON Web Token (JWT) query string auth for streaming endpoints (everything under `/iptv`)
- This can be configured using the following env var (note the double underscore separator `__`)
- `JWT__ISSUERSIGNINGKEY`
- When configured, a JWT signed with the configured signing key is required to be passed in the query string as `access_token`, for example:
- `http://localhost:8409/iptv/channels.m3u?access_token=ABCDEF`
- `http://localhost:8409/iptv/xmltv.xml?access_token=ABCDEF`
- When channels are retrieved this way, the access token will automatically be passed through to all necessary urls
- Note that ONLY the `/iptv` endpoints will require auth when JWT is configured
### Fixed
- Fix scaling anamorphic content from non-local libraries
- Fix direct streaming content from Jellyfin that has external subtitles
- Note that these subtitles are not currently supported in ETV, but they did cause a playback issue
- Fix Jellyfin, Emby and Plex library scans that wouldn't work in certain timezones
- Fix song normalization to match FFmpeg Profile bit depth
- Fix bug playing some external subtitle files (e.g. with an apostrophe in the file name)
- Fix bug detecting VAAPI capabilities when no device is selected in active FFmpeg Profile
- Fix playout mode duration bugs in XMLTV
- Tail mode filler will properly include filler duration in XMLTV
- Duration that wraps across midnight will no longer have overlapping items in XMLTV
- Maintain collection progress across all alternate schedules on a playout
- Fix color normalization from `bt470bg` to `bt709`
### Changed
- Ignore case of video and audio file extensions in local folder scanner
- For example, the scanner will now find `movie.MKV` as well as `movie.mkv` on case-sensitive filesystems
- Include multiple `display-name` entries in generated XMLTV
- Plex should now display the channel number instead of the channel id (e.g. `1.2` instead of `1.2.etv`)
- Rework concurrency a bit
- Playout builds are no longer blocked by library scans
- Adding Trakt lists is no longer blocked by library scans
- All library scans (local and media servers) run sequentially
- Emby collection scanning will no longer happen after every (automatic or forced) library scan
- Automatic/periodic scans will check collections one time after all libraries have been scanned
- There is a new table in the `Media` > `Libraries` page with a button to manually re-scan Emby collections as needed
- For performance reasons, limit console log output to errors on Windows
- Other platforms are unchanged
- Log file behavior is unchanged
## [0.7.5-beta] - 2023-03-05
### Added
- Use AV1 hardware-accelerated decoder with VAAPI, QSV, NVIDIA when available
- Use VP9 hardware-accelerated decoder with VAAPI when available
### Fixed
- Align default docker image (no acceleration) with new images from [ErsatzTV-ffmpeg](https://github.com/ErsatzTV/ErsatzTV-ffmpeg)
- Fix some transcoding pipelines that use software decoders
- Improve VAAPI encoder capability detection on newer hardware
- Fix trash page to properly display episodes with missing metadata or titles
- Fix playback of content with yuv444p10le pixel format
- Fix case where some multi-episode files from Plex would crash the scanner
### Changed
- Upgrade all docker images and windows builds to ffmpeg 6.0
- Plex, Jellyfin and Emby libraries now retrieve all metadata and statistics from the media server
- File systems will no longer be periodically scanned for libraries using these media sources
- Plex, Jellyfin and Emby libraries now direct stream content when files are not found on ErsatzTV's file system
- Content will still be normalized according to the Channel and FFmpeg Profile settings
- Streaming from disk is preferred, so every playback attempt will first check the local file system
- Use libvpl instead of libmfx to provide intel acceleration in vaapi docker images
- Search queries no longer remove duplicate results as this was causing incorrect behavior
- Prioritize audio streams that are flagged as "default" over number of audio channels
- For example, a video with a stereo commentary track and a mono "default" track will now prefer the "default" track
- Support many more season folder names with local television libraries
## [0.7.4-beta] - 2023-02-12
### Added
- Add button to copy/clone schedule from schedules table
- Synchronize episode tags and genres from Jellyfin, Emby and Local show libraries
- Add `Deep Scan` button to Jellyfin and Emby libraries
- This is now required to update some metadata for existing libraries, when targeted updates are not possible
- For example, if you already have tags and genres on your episodes in Jellyfin or Emby, you will need to deep scan each library to update that metadata on existing items in ErsatzTV
### Fixed
- Fix many QSV pipeline bugs
- Fix MPEG2 video format with QSV and VAAPI acceleration
- Fix playback of content with undefined colorspace
- Fix NVIDIA color normalization with VP9 sources
- Fix fallback filler looping
- Fix bug where some libraries would never scan
- Fix filler ordering so post-roll is properly scheduled after padded mid-roll
- Fix pre/post-roll filler padding when used with mid-roll
- This caused overlapping schedule items, fallback filler that was too long, etc.
### Changed
- Merge generated `Other Video` folder tags with tags from sidecar NFO
- Prioritize audio streams that are flagged as "default" when multiple candidate streams are available
- For example, a video with a stereo commentary track and a stereo "default" track will now prefer the "default" track
## [0.7.3-beta] - 2023-01-25
### Added
- Attempt to release memory periodically
- Add OpenID Connect (OIDC) support (e.g. Keycloak, Authelia, Auth0)
- This only protects the management UI; all streaming endpoints will continue to allow anonymous access
- This can be configured with the following env vars (note the double underscore separator `__`)
- `OIDC__AUTHORITY`
- `OIDC__CLIENTID`
- `OIDC__CLIENTSECRET`
- `OIDC__LOGOUTURI` (optional, needed for Auth0, use `https://{auth0-domain}/v2/logout?client_id={auth0-client-id}` with proper values for domain and client-id)
- Add *experimental* alternate schedule system
- This allows a single playout to dynamically select a schedule based on date criteria, for example:
- Weekday vs weekend schedules
- Summer vs fall schedules
- Shark week schedules
- Alternate schedules can be managed by clicking the calendar icon in the playout list
- Playouts contain a prioritized (top to bottom) list of alternate schedules
- Whenever a playout is built for a given day, ErsatzTV will check for a matching schedule from top to bottom
- A given day must match all alternate schedule parameters; wildcards (`*any*`) will always match
- Day of week
- Day of month
- Month
- The lowest priority (bottom) item will always match all parameters, and can be considered a "default" or "fallback" schedule
### Fixed
- Fix schedule editor crashing due to bad music video artist data
- Fix bug where playouts would not maintain smart collection progress on schedules that use multiple smart collections
- Fix library scanning on osx-arm64
- Fix ability to remove some media server libraries from ErsatzTV
### Changed
- Always use software pipeline for error display
- This ensures errors will display even when hardware acceleration is misconfigured
- Call scanner process only when scanning is required based on library refresh interval
- Use lower process priority for scanner process with unforced (automatic) library scans
- Disable V2 UI and APIs by default
- V2 UI can be re-enabled by setting the env var `ETV_UI_V2` to any value
## [0.7.2-beta] - 2023-01-05
### Fixed
- Fix VAAPI encoding in docker by switching to non-free driver
### Changed
- Rewrite log page to read directly from log files instead of sqlite
## [0.7.1-beta] - 2023-01-03
### Added
- Add new music video credit templates
### Fixed
- Fix many transcoding failures caused by the colorspace filter
- Fix song playback with VAAPI and NVENC
- Fix edge case where some local movies would not automatically be restored from trash
- Fix synchronizing Jellyfin and Emby collection items
- Fix saving some external subtitle records to database
### Changed
- Upgrade to dotnet 7
- Upgrade all docker images to ubuntu jammy and ffmpeg 5.1.2
- Limit library scan interval between 0 and 1,000,000
- 0 means do not automatically scan libraries
- 1 to 999,999 means scan if it has been that many hours since the last scan
- Use new `ErsatzTV.Scanner` process for scanning all libraries
- This should reduce the ongoing memory footprint
## [0.7.0-beta] - 2022-12-11
### Fixed
- Fix removing Jellyfin and Emby libraries that have been deleted from the source media server
- Fix `Work-Ahead HLS Segmenter Limit` setting to properly limit number of channels that can work-ahead at once
- Include base path value in generated channel playlist (M3U) and channel guide (XMLTV) links
- Fix parsing song metadata from OGG audio files
- Properly unlock/re-enable trakt list operations after an operation is canceled
### Added
- Add (required) bit depth normalization option to ffmpeg profile
- This can help if your card only supports e.g. h264 encoding, normalizing to 8 bits will allow the hardware encoder to be used
- Extract font attachments after extracting text subtitles
- This should improve SubStation Alpha subtitle rendering
- Detect VAAPI capabilities and fallback to software decoding/encoding as needed
- Add audio stream selector scripts for episodes and movies
- This will let you customize which audio stream is selected for playback
- Episodes are passed the following data:
- `channelNumber`
- `channelName`
- `showTitle`
- `showGuids`: array of string ids like `imdb_1234` or `tvdb_1234`
- `seasonNumber`
- `episodeNumber`
- `episodeGuids`: array of string ids like `imdb_1234` or `tvdb_1234`
- `preferredLanguageCodes`: array of string preferred language codes configured for the channel
- `audioStreams`: array of audio stream data, each containing
- `index`: the stream's index number, this is what the function needs to return
- `channels`: the number of audio channels
- `codec`: the audio codec
- `isDefault`: bool indicating whether the stream is flagged as default
- `isForced`: bool indicating whether the stream is flagged as forced
- `language`: the stream's language
- `title`: the stream's title
- Movies are passed the following data:
- `channelNumber`
- `channelName`
- `title`
- `guids`: array of string ids like `imdb_1234` or `tvdb_1234`
- `preferredLanguageCodes`: array of string preferred language codes configured for the channel
- `audioStreams`: array of audio stream data, each containing
- `index`: the stream's index number, this is what the function needs to return
- `channels`: the number of audio channels
- `codec`: the audio codec
- `isDefault`: bool indicating whether the stream is flagged as default
- `isForced`: bool indicating whether the stream is flagged as forced
- `language`: the stream's language
- `title`: the stream's title
- Add new fields to search index
- `video_codec`: the video codec
- `video_bit_depth`: the number of bits in the video stream's pixel format, e.g. 8 or 10
- `video_dynamic_range`: the video's dynamic range, either `sdr` or `hdr`
### Changed
- Change `Multi-Episode Shuffle` scripting system to use Javascript instead of Lua
## [0.6.9-beta] - 2022-10-21
### Fixed
- Fix bug where tail or fallback filler would sometimes schedule much longer than expected
- This only happened with fixed start schedule items following a schedule item with tail or fallback filler
- Fix NFO reader bug that caused inaccurate warning messages about invalid XML and incomplete metadata
- Fix reverse proxy SSL termination support by supporting `X-Forwarded-Proto` header
- Fix automatic playout reset scheduling
- Playouts would reset every 30 minutes between midnight and the configured time, instead of only at the configured time
- XMLTV: properly group schedule items with `Custom Title` followed by item(s) with `Guide Mode` set to `Filler`
### Added
- Add music video credits template system
- Templates are selected in each channel's settings
- Templates should be copied from `_default.ass.sbntxt` which is located in the config subfolder `templates/music-video-credits`
- Copy the file, give it any name ending with `.ass.sbntext`, and only make edits to the copied file
- The default template will be extracted and overwritten every time ErsatzTV is started
- The template is an [Advanced SubStation Alpha](http://www.tcax.org/docs/ass-specs.htm) file using [scribian](https://github.com/scriban/scriban/tree/master/doc) template syntax
- The following fields are available for use in the template:
- `resolution`: the ffmpeg profile's resolution, which is used for margin calculations
- `title`: the title of the music video
- `track`: the music video's track number
- `album`: the music video's album
- `plot`: the music video's plot
- `release_date`: the music video's release date
- `artist`: the music videos artist (the parent folder)
- `all_artists`: a list of additional artists from the music video's sidecar NFO metadata file
- `duration`: the timespan duration of the music video, which can be used to calculate timing of additional subtitles
- `stream_seek`: the timespan that ffmpeg will seek into the media item before beginning playback
- Add `Multi-Episode Shuffle` playout order for `Television Show` schedule items
- The purpose of this playout order is to improve randomization for shows that normally have intro, multiple episodes, and outro
- This playout order requires splitting the parts into individual files (e.g. splitting `s01e01-03.mkv` into `s01e01.mkv`, `s01e02.mkv` and `s01e03.mkv`)
- This playout order requires a lua script in the config subfolder `scripts/multi-episode-shuffle`
- The lua script should be named for the television show's guid, e.g. `tvdb_12345.lua` or `imdb_tt123456789.lua`
- The script defines the number of parts that each un-split file typically contains
- The script also defines a function to map each episode to a part number (or no part number i.e. `nil` if an episode has not been split)
- All groups of part numbers (i.e. all part 1s, all part 2s) will be shuffled
- The playout order will then schedule a random part 1 followed by a random part 2, etc
- Un-split (`nil`) episodes will be randomly placed between re-combined parts (e.g. part1, part2, part3, un-split, part1, part2, part3)
- Add `ETV_BASE_URL` environment variable to support reverse proxies that use paths (e.g. `/ersatztv`)
### Changed
- No longer place watermarks within content by default (e.g. within 4:3 content padded to a 16:9 resolution)
- This can be re-enabled if desired using the `Place Within Source Content` checkbox in watermark settings
## [0.6.8-beta] - 2022-10-05
### Fixed
- Fix typo introduced in `0.6.7-beta` that stopped QSV HEVC encoder from working
@@ -1337,109 +1741,123 @@ 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/jasongdove/ErsatzTV/compare/v0.6.8-beta...HEAD
[0.6.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.7-beta...v0.6.8-beta
[0.6.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.6-beta...v0.6.7-beta
[0.6.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.5-beta...v0.6.6-beta
[0.6.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.4-beta...v0.6.5-beta
[0.6.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.3-beta...v0.6.4-beta
[0.6.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.2-beta...v0.6.3-beta
[0.6.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.1-beta...v0.6.2-beta
[0.6.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.0-beta...v0.6.1-beta
[0.6.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.8-beta...v0.6.0-beta
[0.5.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.7-beta...v0.5.8-beta
[0.5.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.6-beta...v0.5.7-beta
[0.5.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.5-beta...v0.5.6-beta
[0.5.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.4-beta...v0.5.5-beta
[0.5.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.3-beta...v0.5.4-beta
[0.5.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.2-beta...v0.5.3-beta
[0.5.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.1-beta...v0.5.2-beta
[0.5.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.0-beta...v0.5.1-beta
[0.5.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.5-alpha...v0.5.0-beta
[0.4.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.4-alpha...v0.4.5-alpha
[0.4.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.3-alpha...v0.4.4-alpha
[0.4.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.2-alpha...v0.4.3-alpha
[0.4.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.1-alpha...v0.4.2-alpha
[0.4.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.0-alpha...v0.4.1-alpha
[0.4.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.8-alpha...v0.4.0-alpha
[0.3.8-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.7-alpha...v0.3.8-alpha
[0.3.7-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.6-alpha...v0.3.7-alpha
[0.3.6-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.5-alpha...v0.3.6-alpha
[0.3.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.4-alpha...v0.3.5-alpha
[0.3.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.3-alpha...v0.3.4-alpha
[0.3.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.2-alpha...v0.3.3-alpha
[0.3.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.1-alpha...v0.3.2-alpha
[0.3.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...v0.3.1-alpha
[0.3.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.5-alpha...v0.3.0-alpha
[0.2.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.4-alpha...v0.2.5-alpha
[0.2.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.3-alpha...v0.2.4-alpha
[0.2.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.2-alpha...v0.2.3-alpha
[0.2.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.1-alpha...v0.2.2-alpha
[0.2.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.0-alpha...v0.2.1-alpha
[0.2.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.5-alpha...v0.2.0-alpha
[0.1.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.4-alpha...v0.1.5-alpha
[0.1.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.3-alpha...v0.1.4-alpha
[0.1.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.2-alpha...v0.1.3-alpha
[0.1.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.1-alpha...v0.1.2-alpha
[0.1.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-alpha...v0.1.1-alpha
[0.1.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...v0.1.0-alpha
[0.0.62-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
[0.0.61-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
[0.0.60-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...v0.0.60-alpha
[0.0.59-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.58-alpha
[0.0.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
[0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
[0.0.54-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
[0.0.53-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.52-alpha...v0.0.53-alpha
[0.0.52-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.51-alpha...v0.0.52-alpha
[0.0.51-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.50-alpha...v0.0.51-alpha
[0.0.50-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.49-prealpha...v0.0.50-alpha
[0.0.49-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.48-prealpha...v0.0.49-prealpha
[0.0.48-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.47-prealpha...v0.0.48-prealpha
[0.0.47-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.46-prealpha...v0.0.47-prealpha
[0.0.46-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.45-prealpha...v0.0.46-prealpha
[0.0.45-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
[0.0.44-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...v0.0.44-prealpha
[0.0.43-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.42-prealpha...v0.0.43-prealpha
[0.0.42-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.41-prealpha...v0.0.42-prealpha
[0.0.41-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.40-prealpha...v0.0.41-prealpha
[0.0.40-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.39-prealpha...v0.0.40-prealpha
[0.0.39-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.38-prealpha...v0.0.39-prealpha
[0.0.38-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.37-prealpha...v0.0.38-prealpha
[0.0.37-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.36-prealpha...v0.0.37-prealpha
[0.0.36-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.35-prealpha...v0.0.36-prealpha
[0.0.35-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.34-prealpha...v0.0.35-prealpha
[0.0.34-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.33-prealpha...v0.0.34-prealpha
[0.0.33-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.32-prealpha...v0.0.33-prealpha
[0.0.32-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.31-prealpha...v0.0.32-prealpha
[0.0.31-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.29-prealpha...v0.0.31-prealpha
[0.0.29-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.28-prealpha...v0.0.29-prealpha
[0.0.28-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.27-prealpha...v0.0.28-prealpha
[0.0.27-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.26-prealpha...v0.0.27-prealpha
[0.0.26-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.25-prealpha...v0.0.26-prealpha
[0.0.25-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.24-prealpha...v0.0.25-prealpha
[0.0.24-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.23-prealpha...v0.0.24-prealpha
[0.0.23-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.22-prealpha...v0.0.23-prealpha
[0.0.22-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.21-prealpha...v0.0.22-prealpha
[0.0.21-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.20-prealpha...v0.0.21-prealpha
[0.0.20-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.19-prealpha...v0.0.20-prealpha
[0.0.19-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.18-prealpha...v0.0.19-prealpha
[0.0.18-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.17-prealpha...v0.0.18-prealpha
[0.0.17-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.16-prealpha...v0.0.17-prealpha
[0.0.16-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.15-prealpha...v0.0.16-prealpha
[0.0.15-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.14-prealpha...v0.0.15-prealpha
[0.0.14-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.13-prealpha...v0.0.14-prealpha
[0.0.13-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.12-prealpha...v0.0.13-prealpha
[0.0.12-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.11-prealpha...v0.0.12-prealpha
[0.0.11-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.10-prealpha...v0.0.11-prealpha
[0.0.10-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.9-prealpha...v0.0.10-prealpha
[0.0.9-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.8-prealpha...v0.0.9-prealpha
[0.0.8-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.7-prealpha...v0.0.8-prealpha
[0.0.7-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.6-prealpha...v0.0.7-prealpha
[0.0.6-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.5-prealpha...v0.0.6-prealpha
[0.0.5-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.4-prealpha...v0.0.5-prealpha
[0.0.4-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.3-prealpha...v0.0.4-prealpha
[0.0.3-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.1-prealpha...v0.0.3-prealpha
[0.0.1-prealpha]: https://github.com/jasongdove/ErsatzTV/releases/tag/v0.0.1-prealpha
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.2-beta...HEAD
[0.8.2-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.1-beta...v0.8.2-beta
[0.8.1-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.0-beta...v0.8.1-beta
[0.8.0-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.9-beta...v0.8.0-beta
[0.7.9-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.8-beta...v0.7.9-beta
[0.7.8-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.7-beta...v0.7.8-beta
[0.7.7-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.6-beta...v0.7.7-beta
[0.7.6-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.5-beta...v0.7.6-beta
[0.7.5-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.4-beta...v0.7.5-beta
[0.7.4-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.3-beta...v0.7.4-beta
[0.7.3-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.2-beta...v0.7.3-beta
[0.7.2-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.1-beta...v0.7.2-beta
[0.7.1-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.0-beta...v0.7.1-beta
[0.7.0-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.6.9-beta...v0.7.0-beta
[0.6.9-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.6.8-beta...v0.6.9-beta
[0.6.8-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.6.7-beta...v0.6.8-beta
[0.6.7-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.6.6-beta...v0.6.7-beta
[0.6.6-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.6.5-beta...v0.6.6-beta
[0.6.5-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.6.4-beta...v0.6.5-beta
[0.6.4-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.6.3-beta...v0.6.4-beta
[0.6.3-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.6.2-beta...v0.6.3-beta
[0.6.2-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.6.1-beta...v0.6.2-beta
[0.6.1-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.6.0-beta...v0.6.1-beta
[0.6.0-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.5.8-beta...v0.6.0-beta
[0.5.8-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.5.7-beta...v0.5.8-beta
[0.5.7-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.5.6-beta...v0.5.7-beta
[0.5.6-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.5.5-beta...v0.5.6-beta
[0.5.5-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.5.4-beta...v0.5.5-beta
[0.5.4-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.5.3-beta...v0.5.4-beta
[0.5.3-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.5.2-beta...v0.5.3-beta
[0.5.2-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.5.1-beta...v0.5.2-beta
[0.5.1-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.5.0-beta...v0.5.1-beta
[0.5.0-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.4.5-alpha...v0.5.0-beta
[0.4.5-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.4.4-alpha...v0.4.5-alpha
[0.4.4-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.4.3-alpha...v0.4.4-alpha
[0.4.3-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.4.2-alpha...v0.4.3-alpha
[0.4.2-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.4.1-alpha...v0.4.2-alpha
[0.4.1-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.4.0-alpha...v0.4.1-alpha
[0.4.0-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.3.8-alpha...v0.4.0-alpha
[0.3.8-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.3.7-alpha...v0.3.8-alpha
[0.3.7-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.3.6-alpha...v0.3.7-alpha
[0.3.6-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.3.5-alpha...v0.3.6-alpha
[0.3.5-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.3.4-alpha...v0.3.5-alpha
[0.3.4-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.3.3-alpha...v0.3.4-alpha
[0.3.3-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.3.2-alpha...v0.3.3-alpha
[0.3.2-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.3.1-alpha...v0.3.2-alpha
[0.3.1-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.3.0-alpha...v0.3.1-alpha
[0.3.0-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.2.5-alpha...v0.3.0-alpha
[0.2.5-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.2.4-alpha...v0.2.5-alpha
[0.2.4-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.2.3-alpha...v0.2.4-alpha
[0.2.3-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.2.2-alpha...v0.2.3-alpha
[0.2.2-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.2.1-alpha...v0.2.2-alpha
[0.2.1-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.2.0-alpha...v0.2.1-alpha
[0.2.0-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.1.5-alpha...v0.2.0-alpha
[0.1.5-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.1.4-alpha...v0.1.5-alpha
[0.1.4-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.1.3-alpha...v0.1.4-alpha
[0.1.3-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.1.2-alpha...v0.1.3-alpha
[0.1.2-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.1.1-alpha...v0.1.2-alpha
[0.1.1-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.1.0-alpha...v0.1.1-alpha
[0.1.0-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.62-alpha...v0.1.0-alpha
[0.0.62-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
[0.0.61-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
[0.0.60-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.59-alpha...v0.0.60-alpha
[0.0.59-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha
[0.0.58-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.57-alpha...v0.0.58-alpha
[0.0.57-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
[0.0.56-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
[0.0.55-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
[0.0.54-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
[0.0.53-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.52-alpha...v0.0.53-alpha
[0.0.52-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.51-alpha...v0.0.52-alpha
[0.0.51-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.50-alpha...v0.0.51-alpha
[0.0.50-alpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.49-prealpha...v0.0.50-alpha
[0.0.49-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.48-prealpha...v0.0.49-prealpha
[0.0.48-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.47-prealpha...v0.0.48-prealpha
[0.0.47-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.46-prealpha...v0.0.47-prealpha
[0.0.46-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.45-prealpha...v0.0.46-prealpha
[0.0.45-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
[0.0.44-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.43-prealpha...v0.0.44-prealpha
[0.0.43-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.42-prealpha...v0.0.43-prealpha
[0.0.42-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.41-prealpha...v0.0.42-prealpha
[0.0.41-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.40-prealpha...v0.0.41-prealpha
[0.0.40-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.39-prealpha...v0.0.40-prealpha
[0.0.39-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.38-prealpha...v0.0.39-prealpha
[0.0.38-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.37-prealpha...v0.0.38-prealpha
[0.0.37-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.36-prealpha...v0.0.37-prealpha
[0.0.36-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.35-prealpha...v0.0.36-prealpha
[0.0.35-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.34-prealpha...v0.0.35-prealpha
[0.0.34-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.33-prealpha...v0.0.34-prealpha
[0.0.33-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.32-prealpha...v0.0.33-prealpha
[0.0.32-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.31-prealpha...v0.0.32-prealpha
[0.0.31-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.29-prealpha...v0.0.31-prealpha
[0.0.29-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.28-prealpha...v0.0.29-prealpha
[0.0.28-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.27-prealpha...v0.0.28-prealpha
[0.0.27-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.26-prealpha...v0.0.27-prealpha
[0.0.26-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.25-prealpha...v0.0.26-prealpha
[0.0.25-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.24-prealpha...v0.0.25-prealpha
[0.0.24-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.23-prealpha...v0.0.24-prealpha
[0.0.23-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.22-prealpha...v0.0.23-prealpha
[0.0.22-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.21-prealpha...v0.0.22-prealpha
[0.0.21-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.20-prealpha...v0.0.21-prealpha
[0.0.20-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.19-prealpha...v0.0.20-prealpha
[0.0.19-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.18-prealpha...v0.0.19-prealpha
[0.0.18-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.17-prealpha...v0.0.18-prealpha
[0.0.17-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.16-prealpha...v0.0.17-prealpha
[0.0.16-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.15-prealpha...v0.0.16-prealpha
[0.0.15-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.14-prealpha...v0.0.15-prealpha
[0.0.14-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.13-prealpha...v0.0.14-prealpha
[0.0.13-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.12-prealpha...v0.0.13-prealpha
[0.0.12-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.11-prealpha...v0.0.12-prealpha
[0.0.11-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.10-prealpha...v0.0.11-prealpha
[0.0.10-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.9-prealpha...v0.0.10-prealpha
[0.0.9-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.8-prealpha...v0.0.9-prealpha
[0.0.8-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.7-prealpha...v0.0.8-prealpha
[0.0.7-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.6-prealpha...v0.0.7-prealpha
[0.0.6-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.5-prealpha...v0.0.6-prealpha
[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

2
ErsatzTV-Windows/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target/

1035
ErsatzTV-Windows/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
[package]
name = "ersatztv_windows"
version = "0.1.0"
edition = "2021"
[dependencies]
tray-item = { git = "https://github.com/olback/tray-item-rs" }
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
process_path = "0.1.4"
[dependencies.windows]
version = "0.43.0"
features = [
"Win32_System_Console",
"Win32_Foundation"
]
[build-dependencies]
windres = "*"
static_vcruntime = "2.0"

View File

@@ -1,33 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net6.0-windows</TargetFramework>
<RootNamespace>ErsatzTV_Windows</RootNamespace>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>Ersatztv.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Content Include="Ersatztv.ico">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Program.cs">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Compile>
</ItemGroup>
</Project>

View File

@@ -1,14 +0,0 @@
namespace ErsatzTV_Windows;
public static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
public static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new TrayApplicationContext());
}
}

View File

@@ -1,81 +0,0 @@
using ErsatzTV.Core;
using System.Diagnostics;
using CliWrap;
namespace ErsatzTV_Windows;
public class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _trayIcon;
private readonly CancellationTokenSource _tokenSource;
public TrayApplicationContext()
{
_trayIcon = new NotifyIcon
{
Icon = new Icon("./Ersatztv.ico"),
ContextMenuStrip = new ContextMenuStrip(),
Visible = true
};
_tokenSource = new CancellationTokenSource();
AddMenuItem("Launch Web UI", LaunchWebUI);
AddMenuItem("Show Logs", ShowLogs);
_trayIcon.ContextMenuStrip.Items.Add(new ToolStripSeparator());
AddMenuItem("Exit", Exit);
string folder = AppContext.BaseDirectory;
string exe = Path.Combine(folder, "ErsatzTV.exe");
if (File.Exists(exe))
{
Cli.Wrap(exe)
.WithWorkingDirectory(folder)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(_tokenSource.Token);
}
}
private void AddMenuItem(string name, EventHandler action)
{
var item = new ToolStripMenuItem(name);
item.Click += action;
_trayIcon.ContextMenuStrip.Items.Add(item);
}
private void LaunchWebUI(object? sender, EventArgs e)
{
var process = new Process();
process.StartInfo.UseShellExecute = true;
process.StartInfo.FileName = "http://localhost:8409";
process.Start();
}
private void ShowLogs(object? sender, EventArgs e)
{
if (!Directory.Exists(FileSystemLayout.LogsFolder))
{
Directory.CreateDirectory(FileSystemLayout.LogsFolder);
}
var process = new Process();
process.StartInfo.UseShellExecute = true;
process.StartInfo.FileName = FileSystemLayout.LogsFolder;
process.Start();
}
protected override void Dispose(bool disposing)
{
_tokenSource?.Cancel();
base.Dispose(disposing);
}
private void Exit(object? sender, EventArgs e)
{
// Hide tray icon, otherwise it will remain shown until user mouses over it
_trayIcon.Visible = false;
Application.Exit();
}
}

View File

@@ -0,0 +1,6 @@
use windres::Build;
fn main() {
static_vcruntime::metabuild();
Build::new().compile("ersatztv_windows.rc").unwrap();
}

View File

@@ -0,0 +1,2 @@
id ICON "ersatztv.ico"
ersatztv-icon ICON "ersatztv.ico"

View File

@@ -0,0 +1,112 @@
#![windows_subsystem = "windows"]
use special_folder::SpecialFolder;
use std::fs;
use std::os::windows::process::CommandExt;
use std::process::Child;
use std::process::Command;
use std::process::Stdio;
use windows::Win32::System::Console;
use {std::sync::mpsc, tray_item::TrayItem};
const CREATE_NO_WINDOW: u32 = 0x08000000;
enum Message {
Exit,
}
fn main() {
let mut tray = TrayItem::new("ErsatzTV", "ersatztv-icon").unwrap();
let (tx, rx) = mpsc::channel();
tray.add_menu_item("Launch Web UI", || {
let _ = Command::new("cmd")
.creation_flags(CREATE_NO_WINDOW)
.arg("/C")
.arg("start")
.arg("http://localhost:8409")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
})
.unwrap();
tray.add_menu_item("Show Logs", || {
let path = SpecialFolder::LocalApplicationData
.get()
.unwrap()
.join("ersatztv")
.join("logs");
match path.to_str() {
None => {}
Some(folder) => {
fs::create_dir_all(folder).unwrap();
let _ = Command::new("cmd")
.creation_flags(CREATE_NO_WINDOW)
.arg("/C")
.arg("start")
.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;
}
_ => {}
}
}
}

View File

@@ -0,0 +1,5 @@
[*.cs]
# disable CA1711: Identifiers should not have incorrect suffix
dotnet_diagnostic.ca1711.severity = none
# disable CA1848: Use the LoggerMessage delegates
dotnet_diagnostic.ca1848.severity = none

View File

@@ -1,22 +1,41 @@
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.Artists;
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
{
private readonly IArtistRepository _artistRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
public GetAllArtistsHandler(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
public Task<List<NamedMediaItemViewModel>> Handle(
public async Task<List<NamedMediaItemViewModel>> Handle(
GetAllArtists request,
CancellationToken cancellationToken) =>
_artistRepository.GetAllArtists()
.Map(
list => list.Filter(
a => !string.IsNullOrWhiteSpace(
a.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => string.Empty))))
.Map(list => list.Map(ProjectToViewModel).ToList());
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Artist> allArtists = await dbContext.Artists
.AsNoTracking()
.Include(a => a.ArtistMetadata)
.ToListAsync(cancellationToken);
return allArtists.Bind(a => ProjectArtist(a)).ToList();
}
private static Option<NamedMediaItemViewModel> ProjectArtist(Artist a)
{
foreach (ArtistMetadata metadata in a.ArtistMetadata.HeadOrNone())
{
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
return ProjectToViewModel(a);
}
}
return None;
}
}

View File

@@ -18,4 +18,5 @@ public record ChannelViewModel(
int PlayoutCount,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode);
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate);

View File

@@ -18,4 +18,5 @@ public record CreateChannel
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, CreateChannelResult>>;
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, CreateChannelResult>>;

View File

@@ -74,7 +74,8 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
PreferredAudioTitle = request.PreferredAudioTitle,
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate
};
foreach (int id in watermarkId)

View File

@@ -2,4 +2,4 @@
namespace ErsatzTV.Application.Channels;
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Task>>;
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Unit>>;

View File

@@ -1,23 +1,61 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Task>>
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
{
private readonly IChannelRepository _channelRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public DeleteChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public DeleteChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem)
{
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
}
public async Task<Either<BaseError, Task>> Handle(DeleteChannel request, CancellationToken cancellationToken) =>
(await ChannelMustExist(request))
.Map(DoDeletion)
.ToEither<Task>();
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);
private Task DoDeletion(int channelId) => _channelRepository.Delete(channelId);
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c, cancellationToken));
}
private async Task<Validation<BaseError, int>> ChannelMustExist(DeleteChannel deleteChannel) =>
(await _channelRepository.Get(deleteChannel.ChannelId))
.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.")
.Map(c => c.Id);
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
{
dbContext.Channels.Remove(channel);
await dbContext.SaveChangesAsync(cancellationToken);
// delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (_localFileSystem.FileExists(cacheFile))
{
File.Delete(cacheFile);
}
// refresh channel list to remove channel that has no playout
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Unit.Default;
}
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
TvContext dbContext,
DeleteChannel deleteChannel)
{
Option<Channel> maybeChannel = await dbContext.Channels
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId);
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record RefreshChannelData(string ChannelNumber) : IRequest, IBackgroundServiceRequest;

View File

@@ -0,0 +1,525 @@
using System.Globalization;
using System.Xml;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
namespace ErsatzTV.Application.Channels;
public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelDataHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
public RefreshChannelDataHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem,
ILogger<RefreshChannelDataHandler> logger)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
_logger = logger;
}
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
{
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<PlayoutItem> sorted = 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)
.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(em => em.Genres)
.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 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).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)
.ToListAsync(cancellationToken)
.Map(list => list.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList());
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
sorted[i].FillerKind != FillerKind.PreRoll)
{
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
int j = i;
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
{
j++;
}
PlayoutItem displayItem = sorted[j];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.Tail or FillerKind.Fallback))
{
finishIndex++;
}
int customShowId = -1;
if (displayItem.MediaItem is Episode ep)
{
customShowId = ep.Season.ShowId;
}
bool isSameCustomShow = hasCustomTitle;
for (int x = j; x <= finishIndex; x++)
{
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
customShowId == e.Season.ShowId;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = displayItem.GuideFinishOffset.HasValue
? displayItem.GuideFinishOffset.Value.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty)
: finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string title = GetTitle(displayItem);
string subtitle = GetSubtitle(displayItem);
string description = GetDescription(displayItem);
Option<ContentRating> contentRating = GetContentRating(displayItem);
await xml.WriteStartElementAsync(null, "programme", null);
await xml.WriteAttributeStringAsync(null, "start", null, start);
await xml.WriteAttributeStringAsync(null, "stop", null, stop);
await xml.WriteAttributeStringAsync(null, "channel", null, $"{request.ChannelNumber}.etv");
await xml.WriteStartElementAsync(null, "title", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync(title);
await xml.WriteEndElementAsync(); // title
if (!string.IsNullOrWhiteSpace(subtitle))
{
await xml.WriteStartElementAsync(null, "sub-title", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync(subtitle);
await xml.WriteEndElementAsync(); // subtitle
}
if (!isSameCustomShow)
{
if (!string.IsNullOrWhiteSpace(description))
{
await xml.WriteStartElementAsync(null, "desc", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync(description);
await xml.WriteEndElementAsync(); // desc
}
}
if (!hasCustomTitle && displayItem.MediaItem is Movie movie)
{
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
{
if (metadata.Year.HasValue)
{
await xml.WriteStartElementAsync(null, "date", null);
await xml.WriteStringAsync(metadata.Year.Value.ToString(CultureInfo.InvariantCulture));
await xml.WriteEndElementAsync(); // date
}
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync("Movie");
await xml.WriteEndElementAsync(); // category
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
{
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync(genre.Name);
await xml.WriteEndElementAsync(); // category
}
string poster = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
.HeadOrNone()
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
if (!string.IsNullOrWhiteSpace(poster))
{
await xml.WriteStartElementAsync(null, "icon", null);
await xml.WriteAttributeStringAsync(null, "src", null, poster);
await xml.WriteEndElementAsync(); // icon
}
}
}
if (!hasCustomTitle && displayItem.MediaItem is MusicVideo musicVideo)
{
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata.HeadOrNone())
{
if (metadata.Year.HasValue)
{
await xml.WriteStartElementAsync(null, "date", null);
await xml.WriteStringAsync(metadata.Year.Value.ToString(CultureInfo.InvariantCulture));
await xml.WriteEndElementAsync(); // date
}
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync("Music");
await xml.WriteEndElementAsync(); // category
// music video genres
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
{
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync(genre.Name);
await xml.WriteEndElementAsync(); // category
}
// artist genres
Option<ArtistMetadata> maybeMetadata =
Optional(musicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten();
foreach (ArtistMetadata artistMetadata in maybeMetadata)
{
foreach (Genre genre in Optional(artistMetadata.Genres).Flatten().OrderBy(g => g.Name))
{
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync(genre.Name);
await xml.WriteEndElementAsync(); // category
}
}
string artworkPath = GetPrioritizedArtworkPath(metadata);
if (!string.IsNullOrWhiteSpace(artworkPath))
{
await xml.WriteStartElementAsync(null, "icon", null);
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
await xml.WriteEndElementAsync(); // icon
}
}
}
if (!hasCustomTitle && displayItem.MediaItem is Song song)
{
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync("Music");
await xml.WriteEndElementAsync(); // category
foreach (SongMetadata metadata in song.SongMetadata.HeadOrNone())
{
string artworkPath = GetPrioritizedArtworkPath(metadata);
if (!string.IsNullOrWhiteSpace(artworkPath))
{
await xml.WriteStartElementAsync(null, "icon", null);
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
await xml.WriteEndElementAsync(); // icon
}
}
}
if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
{
Option<ShowMetadata> maybeMetadata =
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
foreach (ShowMetadata metadata in maybeMetadata)
{
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync("Series");
await xml.WriteEndElementAsync(); // category
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
{
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync(genre.Name);
await xml.WriteEndElementAsync(); // category
}
string artworkPath = GetPrioritizedArtworkPath(metadata);
if (!string.IsNullOrWhiteSpace(artworkPath))
{
await xml.WriteStartElementAsync(null, "icon", null);
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
await xml.WriteEndElementAsync(); // icon
}
}
if (!isSameCustomShow)
{
int s = await Optional(episode.Season?.SeasonNumber).IfNoneAsync(-1);
// TODO: multi-episode?
int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1);
if (s >= 0 && e > 0)
{
await xml.WriteStartElementAsync(null, "episode-num", null);
await xml.WriteAttributeStringAsync(null, "system", null, "onscreen");
await xml.WriteStringAsync($"S{s:00}E{e:00}");
await xml.WriteEndElementAsync(); // episode-num
await xml.WriteStartElementAsync(null, "episode-num", null);
await xml.WriteAttributeStringAsync(null, "system", null, "xmltv_ns");
await xml.WriteStringAsync($"{s - 1}.{e - 1}.0/1");
await xml.WriteEndElementAsync(); // episode-num
}
}
}
await xml.WriteStartElementAsync(null, "previously-shown", null);
await xml.WriteEndElementAsync(); // previously-shown
foreach (ContentRating rating in contentRating)
{
await xml.WriteStartElementAsync(null, "rating", null);
foreach (string system in rating.System)
{
await xml.WriteAttributeStringAsync(null, "system", null, system);
}
await xml.WriteStartElementAsync(null, "value", null);
await xml.WriteStringAsync(rating.Value);
await xml.WriteEndElementAsync(); // value
await xml.WriteEndElementAsync(); // rating
}
await xml.WriteEndElementAsync(); // programme
i++;
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
File.Move(tempFile, targetFile, true);
}
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
{
string artworkPath = artwork.Path;
int height = artworkKind switch
{
ArtworkKind.Thumbnail => 220,
_ => 440
};
if (artworkPath.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
}
else if (artworkPath.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
{
artworkPath = EmbyUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
}
else
{
string artworkFolder = artworkKind switch
{
ArtworkKind.Thumbnail => "thumbnails",
_ => "posters"
};
artworkPath = $"{{RequestBase}}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg{{AccessTokenUri}}";
}
return artworkPath;
}
private static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Artist ?? string.Empty)
.IfNone("[unknown artist]"),
_ => "[unknown]"
};
}
private static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
private static string GetDescription(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
.IfNone(string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => mvm.Plot ?? string.Empty)
.IfNone(string.Empty),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Plot ?? string.Empty)
.IfNone(string.Empty),
_ => string.Empty
};
}
private Option<ContentRating> GetContentRating(PlayoutItem playoutItem)
{
try
{
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata
.HeadOrNone()
.Match(mm => ParseContentRating(mm.ContentRating, "MPAA"), () => None),
Episode e => e.Season.Show.ShowMetadata
.HeadOrNone()
.Match(sm => ParseContentRating(sm.ContentRating, "VCHIP"), () => None),
_ => None
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem));
return None;
}
}
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
{
Option<string> maybeFirst = (contentRating ?? string.Empty).Split('/').HeadOrNone();
return maybeFirst.Map(
first =>
{
string[] split = first.Split(':');
if (split.Length == 2)
{
return split[0].ToLowerInvariant() == "us"
? new ContentRating(system, split[1].ToUpperInvariant())
: new ContentRating(None, split[1].ToUpperInvariant());
}
return string.IsNullOrWhiteSpace(first)
? Option<ContentRating>.None
: new ContentRating(None, first);
}).Flatten();
}
private static string GetPrioritizedArtworkPath(Metadata metadata)
{
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
.HeadOrNone()
.Map(a => GetArtworkUrl(a, ArtworkKind.Poster));
if (maybeArtwork.IsNone)
{
maybeArtwork = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Thumbnail)
.HeadOrNone()
.Map(a => GetArtworkUrl(a, ArtworkKind.Thumbnail));
}
return maybeArtwork.IfNone(string.Empty);
}
private sealed record ContentRating(Option<string> System, string Value);
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record RefreshChannelList : IRequest, IBackgroundServiceRequest;

View File

@@ -0,0 +1,116 @@
using System.Data.Common;
using System.Xml;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.IO;
namespace ErsatzTV.Application.Channels;
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
public RefreshChannelListHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
}
public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken)
{
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken))
{
await xml.WriteStartElementAsync(null, "channel", null);
await xml.WriteAttributeStringAsync(null, "id", null, $"{channel.Number}.etv");
await xml.WriteStartElementAsync(null, "display-name", null);
await xml.WriteStringAsync($"{channel.Number} {channel.Name}");
await xml.WriteEndElementAsync(); // display-name (number and name)
await xml.WriteStartElementAsync(null, "display-name", null);
await xml.WriteStringAsync(channel.Number);
await xml.WriteEndElementAsync(); // display-name (number)
await xml.WriteStartElementAsync(null, "display-name", null);
await xml.WriteStringAsync(channel.Name);
await xml.WriteEndElementAsync(); // display-name (name)
foreach (string category in GetCategories(channel.Categories))
{
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync(category);
await xml.WriteEndElementAsync(); // category
}
await xml.WriteStartElementAsync(null, "icon", null);
await xml.WriteAttributeStringAsync(null, "src", null, GetIconUrl(channel));
await xml.WriteEndElementAsync(); // icon
await xml.WriteEndElementAsync(); // channel
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
File.Move(tempFile, targetFile, true);
}
private static async IAsyncEnumerable<ChannelResult> GetChannels(TvContext dbContext)
{
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
from Channel C
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
where C.Id in (select ChannelId from Playout)
order by CAST(C.Number as double)";
// TODO: this needs to be fixed for sqlite/mariadb
await using var reader = (DbDataReader)await dbContext.Connection.ExecuteReaderAsync(QUERY);
Func<DbDataReader, ChannelResult> rowParser = reader.GetRowParser<ChannelResult>();
while (await reader.ReadAsync())
{
yield return rowParser(reader);
}
while (await reader.NextResultAsync())
{
}
}
private static List<string> GetCategories(string categories) =>
(categories ?? string.Empty).Split(',')
.Map(s => s.Trim())
.Filter(s => !string.IsNullOrWhiteSpace(s))
.Distinct()
.ToList();
private static string GetIconUrl(ChannelResult channel) =>
string.IsNullOrWhiteSpace(channel.ArtworkPath)
? "{RequestBase}/iptv/images/ersatztv-500.png{AccessTokenUri}"
: $"{{RequestBase}}/iptv/logos/{channel.ArtworkPath}.jpg{{AccessTokenUri}}";
// ReSharper disable once ClassNeverInstantiated.Local
private sealed record ChannelResult(string Number, string Name, string Categories, string ArtworkPath);
}

View File

@@ -19,4 +19,5 @@ public record UpdateChannel
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, ChannelViewModel>>;
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, ChannelViewModel>>;

View File

@@ -15,13 +15,13 @@ namespace ErsatzTV.Application.Channels;
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdateChannelHandler(
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory)
{
_ffmpegWorkerChannel = ffmpegWorkerChannel;
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
}
@@ -31,7 +31,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
{
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));
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
@@ -46,6 +46,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
c.SubtitleMode = update.SubtitleMode;
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
c.Artwork ??= new List<Artwork>();
if (!string.IsNullOrWhiteSpace(update.Logo))
@@ -84,14 +85,14 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
foreach (Playout playout in maybePlayout)
{
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
}
}
return ProjectToViewModel(c);
}
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
(await ChannelMustExist(dbContext, request), ValidateName(request),
await ValidateNumber(dbContext, request),
ValidatePreferredAudioLanguage(request))

View File

@@ -22,7 +22,8 @@ internal static class Mapper
channel.Playouts?.Count ?? 0,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode,
channel.MusicVideoCreditsMode);
channel.MusicVideoCreditsMode,
channel.MusicVideoCreditsTemplate);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
@@ -44,6 +45,6 @@ internal static class Mapper
StreamingMode.TransportStreamHybrid => "MPEG-TS",
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
_ => throw new ArgumentOutOfRangeException()
_ => throw new ArgumentOutOfRangeException(nameof(channel))
};
}

View File

@@ -10,6 +10,6 @@ public class GetChannelByIdHandler : IRequestHandler<GetChannelById, Option<Chan
public GetChannelByIdHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
_channelRepository.Get(request.Id)
_channelRepository.GetChannel(request.Id)
.MapT(ProjectToViewModel);
}

View File

@@ -86,10 +86,20 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
return result;
}
_logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
request.ChannelNumber,
distinct[0]);
if (distinct.Any())
{
_logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
request.ChannelNumber,
distinct[0]);
}
else
{
_logger.LogInformation(
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
request.ChannelNumber);
}
return None;
}

View File

@@ -1,5 +1,7 @@
using ErsatzTV.Core.Iptv;
using ErsatzTV.Core;
using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public record GetChannelGuide(string Scheme, string Host) : IRequest<ChannelGuide>;
public record GetChannelGuide
(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<Either<BaseError, ChannelGuide>>;

View File

@@ -1,23 +1,72 @@
using ErsatzTV.Core.Interfaces.Repositories;
using System.Text;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.IO;
namespace ErsatzTV.Application.Channels;
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
{
private readonly IChannelRepository _channelRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
public GetChannelGuideHandler(
IChannelRepository channelRepository,
RecyclableMemoryStreamManager recyclableMemoryStreamManager)
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
ILocalFileSystem localFileSystem)
{
_channelRepository = channelRepository;
_dbContextFactory = dbContextFactory;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_localFileSystem = localFileSystem;
}
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
_channelRepository.GetAllForGuide()
.Map(channels => new ChannelGuide(_recyclableMemoryStreamManager, request.Scheme, request.Host, channels));
public async Task<Either<BaseError, ChannelGuide>> Handle(
GetChannelGuide request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_localFileSystem.FileExists(channelsFile))
{
return BaseError.New($"Required file {channelsFile} is missing");
}
string accessTokenUri = string.Empty;
if (!string.IsNullOrWhiteSpace(request.AccessToken))
{
accessTokenUri = $"?access_token={request.AccessToken}";
}
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
// TODO: is regex faster?
channelsFragment = channelsFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
var channelDataFragments = new Dictionary<string, string>();
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
{
if (fileName.Contains("channels"))
{
continue;
}
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
}
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelNameByPlayoutId(int PlayoutId) : IRequest<Option<string>>;

View File

@@ -0,0 +1,22 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Include(p => p.Channel)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
.MapT(p => p.Channel.Name);
}
}

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core.Iptv;
using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public record GetChannelPlaylist(string Scheme, string Host, string Mode) : IRequest<ChannelPlaylist>;
public record GetChannelPlaylist
(string Scheme, string Host, string BaseUrl, string Mode, string AccessToken) : IRequest<ChannelPlaylist>;

View File

@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
@@ -14,7 +14,13 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
.Map(channels => EnsureMode(channels, request.Mode))
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels));
.Map(
channels => new ChannelPlaylist(
request.Scheme,
request.Host,
request.BaseUrl,
channels,
request.AccessToken));
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{

View File

@@ -2,4 +2,4 @@
namespace ErsatzTV.Application.Configuration;
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest<Unit>;
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest;

View File

@@ -2,16 +2,13 @@
namespace ErsatzTV.Application.Configuration;
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey, Unit>
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey>
{
private readonly IConfigElementRepository _configElementRepository;
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<Unit> Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
{
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken) =>
await _configElementRepository.Upsert(request.Key, request.Value);
return Unit.Default;
}
}

View File

@@ -24,8 +24,8 @@ public class UpdateLibraryRefreshIntervalHandler :
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
Optional(request.LibraryRefreshInterval)
.Where(lri => lri > 0)
.Where(lri => lri is >= 0 and < 1_000_000)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Tuner count must be greater than zero")
.ToValidation<BaseError>("Library refresh interval must be zero or greated")
.AsTask();
}

View File

@@ -1,4 +1,5 @@
using System.Threading.Channels;
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -45,7 +46,8 @@ public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSetting
List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Channel)
.ToListAsync();
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number, CultureInfo.InvariantCulture))
.Map(p => p.Id))
{
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue));
}

View File

@@ -13,5 +13,5 @@ public class GetConfigElementByKeyHandler : IRequestHandler<GetConfigElementByKe
public Task<Option<ConfigElementViewModel>> Handle(
GetConfigElementByKey request,
CancellationToken cancellationToken) =>
_configElementRepository.Get(request.Key).MapT(ProjectToViewModel);
_configElementRepository.GetConfigElement(request.Key).MapT(ProjectToViewModel);
}

View File

@@ -0,0 +1,83 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Emby;
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
{
public CallEmbyCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
});
}
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeEmbyCollections request)
{
DateTime minDateTime = await dbContext.EmbyMediaSources
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizeEmbyCollections request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
SynchronizeEmbyCollections request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-emby-collections", request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture)
};
if (request.ForceScan)
{
arguments.Add("--force");
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
}
}

View File

@@ -0,0 +1,102 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Emby;
public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeEmbyLibraryById>,
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
{
public CallEmbyLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle(
ForceSynchronizeEmbyLibraryById request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>.
Handle(
SynchronizeEmbyLibraryByIdIfNeeded request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
private async Task<Either<BaseError, string>> Handle(
ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, string>>(error.Join());
});
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-emby", request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture)
};
if (request.ForceScan)
{
arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
}
protected override async Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
ISynchronizeEmbyLibraryById request)
{
DateTime minDateTime = await dbContext.EmbyLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
ISynchronizeEmbyLibraryById request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
}
}

View File

@@ -52,5 +52,5 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
return Unit.Default;
}
private record Parameters(EmbySecrets Secrets, EmbyServerInformation ServerInformation);
private sealed record Parameters(EmbySecrets Secrets, EmbyServerInformation ServerInformation);
}

View File

@@ -1,6 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
namespace ErsatzTV.Application.Emby;
public record SynchronizeEmbyCollections(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IEmbyBackgroundServiceRequest;
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;

View File

@@ -1,73 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Emby;
public class SynchronizeEmbyCollectionsHandler : IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
{
private readonly IEmbySecretStore _embySecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IEmbyCollectionScanner _scanner;
public SynchronizeEmbyCollectionsHandler(
IMediaSourceRepository mediaSourceRepository,
IEmbySecretStore embySecretStore,
IEmbyCollectionScanner scanner)
{
_mediaSourceRepository = mediaSourceRepository;
_embySecretStore = embySecretStore;
_scanner = scanner;
}
public async Task<Either<BaseError, Unit>> Handle(
SynchronizeEmbyCollections request,
CancellationToken cancellationToken)
{
Validation<BaseError, ConnectionParameters> validation = await Validate(request);
return await validation.Match(
SynchronizeCollections,
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
}
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeEmbyCollections request) =>
MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, EmbyMediaSource>> MediaSourceMustExist(
SynchronizeEmbyCollections request) =>
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Emby media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
EmbyMediaSource embyMediaSource)
{
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.HeadOrNone();
return maybeConnection.Map(connection => new ConnectionParameters(connection))
.ToValidation<BaseError>("Emby media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
ConnectionParameters connectionParameters)
{
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Emby media source requires an api key");
}
private async Task<Either<BaseError, Unit>> SynchronizeCollections(ConnectionParameters connectionParameters) =>
await _scanner.ScanCollections(
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey);
private record ConnectionParameters(EmbyConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
}

View File

@@ -3,4 +3,4 @@
namespace ErsatzTV.Application.Emby;
public record SynchronizeEmbyLibraries(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IEmbyBackgroundServiceRequest;
IScannerBackgroundServiceRequest;

View File

@@ -102,9 +102,7 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
return Unit.Default;
}
private record ConnectionParameters(
EmbyMediaSource EmbyMediaSource,
EmbyConnection ActiveConnection)
private sealed record ConnectionParameters(EmbyMediaSource EmbyMediaSource, EmbyConnection ActiveConnection)
{
public string ApiKey { get; set; }
}

View File

@@ -2,19 +2,20 @@
namespace ErsatzTV.Application.Emby;
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>,
IEmbyBackgroundServiceRequest
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest
{
int EmbyLibraryId { get; }
bool ForceScan { get; }
bool DeepScan { get; }
}
public record SynchronizeEmbyLibraryByIdIfNeeded(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
{
public bool ForceScan => false;
public bool DeepScan => false;
}
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId, bool DeepScan) : ISynchronizeEmbyLibraryById
{
public bool ForceScan => true;
}

View File

@@ -8,15 +8,15 @@ namespace ErsatzTV.Application.Emby;
public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmbyMediaSources,
Either<BaseError, List<EmbyMediaSource>>>
{
private readonly ChannelWriter<IEmbyBackgroundServiceRequest> _channel;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
public SynchronizeEmbyMediaSourcesHandler(
IMediaSourceRepository mediaSourceRepository,
ChannelWriter<IEmbyBackgroundServiceRequest> channel)
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel)
{
_mediaSourceRepository = mediaSourceRepository;
_channel = channel;
_scannerWorkerChannel = scannerWorkerChannel;
}
public async Task<Either<BaseError, List<EmbyMediaSource>>> Handle(
@@ -27,7 +27,7 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
foreach (EmbyMediaSource mediaSource in mediaSources)
{
// await _channel.WriteAsync(new SynchronizeEmbyAdminUserId(mediaSource.Id), cancellationToken);
await _channel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
}
return mediaSources;

View File

@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Emby;
public record EmbyConnectionParametersViewModel(string Address);
public record EmbyConnectionParametersViewModel(string Address, string ApiKey);

View File

@@ -9,4 +9,4 @@ public record EmbyLibraryViewModel(
LibraryMediaKind MediaKind,
bool ShouldSyncItems,
int MediaSourceId)
: LibraryViewModel("Emby", Id, Name, MediaKind, MediaSourceId);
: LibraryViewModel("Emby", Id, Name, MediaKind, MediaSourceId, string.Empty);

View File

@@ -1,5 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.Caching.Memory;
@@ -8,15 +10,18 @@ namespace ErsatzTV.Application.Emby;
public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnectionParameters,
Either<BaseError, EmbyConnectionParametersViewModel>>
{
private readonly IEmbySecretStore _embySecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
public GetEmbyConnectionParametersHandler(
IMemoryCache memoryCache,
IMediaSourceRepository mediaSourceRepository)
IMediaSourceRepository mediaSourceRepository,
IEmbySecretStore embySecretStore)
{
_memoryCache = memoryCache;
_mediaSourceRepository = mediaSourceRepository;
_embySecretStore = embySecretStore;
}
public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle(
@@ -30,7 +35,7 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
await Validate()
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address))
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address, cp.ApiKey))
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
return maybeParameters.Match(
@@ -44,7 +49,8 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
EmbyMediaSourceMustExist()
.BindT(MediaSourceMustHaveActiveConnection);
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
@@ -60,7 +66,20 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
.ToValidation<BaseError>("Emby media source requires an active connection");
}
private record ConnectionParameters(
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
ConnectionParameters connectionParameters)
{
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Emby media source requires an api key");
}
private sealed record ConnectionParameters(
EmbyMediaSource EmbyMediaSource,
EmbyConnection ActiveConnection);
EmbyConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
}

View File

@@ -1,22 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.5.0" />
<PackageReference Include="CliWrap" Version="3.6.4" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="11.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
<PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.7.30">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="2.0.0" />
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
</ItemGroup>

View File

@@ -33,6 +33,7 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=plex_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=programschedules_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=programschedules_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Cqueries/@EntryIndexedValue">True</s:Boolean>
@@ -40,5 +41,6 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=subtitles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=television_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=templates_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Cqueries/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -23,10 +23,10 @@ public class
_ffmpegProfileRepository.Copy(request.FFmpegProfileId, request.Name)
.Map(ProjectToViewModel);
private Task<Validation<BaseError, CopyFFmpegProfile>> Validate(CopyFFmpegProfile request) =>
private static Task<Validation<BaseError, CopyFFmpegProfile>> Validate(CopyFFmpegProfile request) =>
ValidateName(request).AsTask().MapT(_ => request);
private Validation<BaseError, string> ValidateName(CopyFFmpegProfile request) =>
private static Validation<BaseError, string> ValidateName(CopyFFmpegProfile request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
}

View File

@@ -13,6 +13,7 @@ public record CreateFFmpegProfile(
int? QsvExtraHardwareFrames,
int ResolutionId,
FFmpegProfileVideoFormat VideoFormat,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
int VideoBufferSize,
FFmpegProfileAudioFormat AudioFormat,

View File

@@ -32,7 +32,7 @@ public class CreateFFmpegProfileHandler :
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
}
private async Task<Validation<BaseError, FFmpegProfile>> Validate(
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext,
CreateFFmpegProfile request) =>
(ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(dbContext, request))
@@ -47,6 +47,7 @@ public class CreateFFmpegProfileHandler :
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
VideoFormat = request.VideoFormat,
BitDepth = request.BitDepth,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
AudioFormat = request.AudioFormat,

View File

@@ -19,7 +19,7 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
return await LanguageExtensions.Apply(validation, p => DoDeletion(dbContext, p));
return await validation.Apply(p => DoDeletion(dbContext, p));
}
private static async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)

View File

@@ -14,6 +14,7 @@ public record UpdateFFmpegProfile(
int? QsvExtraHardwareFrames,
int ResolutionId,
FFmpegProfileVideoFormat VideoFormat,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
int VideoBufferSize,
FFmpegProfileAudioFormat AudioFormat,

View File

@@ -23,7 +23,7 @@ public class
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
}
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
private static async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
TvContext dbContext,
FFmpegProfile p,
UpdateFFmpegProfile update)
@@ -36,6 +36,12 @@ public class
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.VideoFormat = update.VideoFormat;
// mpeg2video only supports 8-bit content
p.BitDepth = update.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video
? FFmpegProfileBitDepth.EightBit
: update.BitDepth;
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.AudioFormat = update.AudioFormat;

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
@@ -52,10 +53,8 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
UseShellExecute = false
};
var test = new Process
{
StartInfo = startInfo
};
using var test = new Process();
test.StartInfo = startInfo;
test.Start();
string output = await test.StandardOutput.ReadToEndAsync();
@@ -71,10 +70,13 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegDefaultProfileId,
request.Settings.DefaultFFmpegProfileId.ToString());
request.Settings.DefaultFFmpegProfileId.ToString(CultureInfo.InvariantCulture));
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegSaveReports,
request.Settings.SaveReports.ToString());
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegHlsDirectOutputFormat,
request.Settings.HlsDirectOutputFormat);
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
{

View File

@@ -14,6 +14,7 @@ public record FFmpegProfileViewModel(
int? QsvExtraHardwareFrames,
ResolutionViewModel Resolution,
FFmpegProfileVideoFormat VideoFormat,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
int VideoBufferSize,
FFmpegProfileAudioFormat AudioFormat,

View File

@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Application.FFmpegProfiles;
public class FFmpegSettingsViewModel
{
@@ -12,4 +14,5 @@ public class FFmpegSettingsViewModel
public int HlsSegmenterIdleTimeout { get; set; }
public int WorkAheadSegmenterLimit { get; set; }
public int InitialSegmentCount { get; set; }
public OutputFormatKind HlsDirectOutputFormat { get; set; }
}

View File

@@ -1,5 +1,4 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles;
@@ -15,8 +14,9 @@ internal static class Mapper
profile.VaapiDriver,
profile.VaapiDevice,
profile.QsvExtraHardwareFrames,
Project(profile.Resolution),
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
profile.VideoFormat,
profile.BitDepth,
profile.VideoBitrate,
profile.VideoBufferSize,
profile.AudioFormat,
@@ -56,7 +56,4 @@ internal static class Mapper
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.DeinterlaceVideo);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
}

View File

@@ -15,7 +15,7 @@ public class GetAllFFmpegProfilesHandler : IRequestHandler<GetAllFFmpegProfiles,
GetAllFFmpegProfiles request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.ToListAsync(cancellationToken)

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Application.FFmpegProfiles;
@@ -32,6 +33,8 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
Option<int> initialSegmentCount =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount);
Option<OutputFormatKind> outputFormatKind =
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat);
var result = new FFmpegSettingsViewModel
{
@@ -42,7 +45,8 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1)
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1),
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs)
};
foreach (int watermarkId in watermark)

View File

@@ -17,9 +17,9 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
DeleteFillerPreset request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => DoDeletion(dbContext, ps));
return await validation.Apply(ps => DoDeletion(dbContext, ps));
}
private static Task<Unit> DoDeletion(TvContext dbContext, FillerPreset fillerPreset)
@@ -28,7 +28,7 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
return dbContext.SaveChangesAsync().ToUnit();
}
private Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
TvContext dbContext,
DeleteFillerPreset request) =>
dbContext.FillerPresets

View File

@@ -17,10 +17,10 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => ApplyUpdateRequest(dbContext, ps, request));
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
}
private async Task<Unit> ApplyUpdateRequest(
private static async Task<Unit> ApplyUpdateRequest(
TvContext dbContext,
FillerPreset existing,
UpdateFillerPreset request)

View File

@@ -15,7 +15,7 @@ public class GetAllFillerPresetsHandler : IRequestHandler<GetAllFillerPresets, L
GetAllFillerPresets request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FillerPresets.ToListAsync(cancellationToken)
.Map(presets => presets.Map(ProjectToViewModel).ToList());
}

View File

@@ -1,5 +1,4 @@
using Dapper;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Filler.Mapper;
@@ -17,14 +16,12 @@ public class GetPagedFillerPresetsHandler : IRequestHandler<GetPagedFillerPreset
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.Connection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM FillerPreset");
List<FillerPresetViewModel> page = await dbContext.FillerPresets.FromSqlRaw(
@"SELECT * FROM FillerPreset
ORDER BY Name
COLLATE NOCASE
LIMIT {0} OFFSET {1}",
request.PageSize,
request.PageNum * request.PageSize)
int count = await dbContext.FillerPresets.CountAsync(cancellationToken);
List<FillerPresetViewModel> page = await dbContext.FillerPresets
.AsNoTracking()
.OrderBy(f => EF.Functions.Collate(f.Name, TvContext.CaseInsensitiveCollation))
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using System.Globalization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -15,7 +16,10 @@ public class UpdateHDHRTunerCountHandler : IRequestHandler<UpdateHDHRTunerCount,
UpdateHDHRTunerCount request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => _configElementRepository.Upsert(ConfigElementKey.HDHRTunerCount, request.TunerCount.ToString()))
.MapT(
_ => _configElementRepository.Upsert(
ConfigElementKey.HDHRTunerCount,
request.TunerCount.ToString(CultureInfo.InvariantCulture)))
.Bind(v => v.ToEitherAsync());
private static Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>

View File

@@ -0,0 +1,5 @@
namespace ErsatzTV.Application;
public interface IScannerBackgroundServiceRequest
{
}

View File

@@ -0,0 +1,5 @@
namespace ErsatzTV.Application;
public interface ISearchIndexBackgroundServiceRequest
{
}

View File

@@ -1,5 +0,0 @@
namespace ErsatzTV.Application;
public interface ISubtitleWorkerRequest
{
}

View File

@@ -0,0 +1,103 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Jellyfin;
public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeJellyfinLibraryById>,
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
{
public CallJellyfinLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>.
Handle(
ForceSynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>.
Handle(
SynchronizeJellyfinLibraryByIdIfNeeded request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
private async Task<Either<BaseError, string>> Handle(
ISynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, string>>(error.Join());
});
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
ISynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-jellyfin", request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture)
};
if (request.ForceScan)
{
arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
}
protected override async Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
ISynchronizeJellyfinLibraryById request)
{
DateTime minDateTime = await dbContext.JellyfinLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId)
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
ISynchronizeJellyfinLibraryById request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
}
}

View File

@@ -52,5 +52,5 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
return Unit.Default;
}
private record Parameters(JellyfinSecrets Secrets, JellyfinServerInformation ServerInformation);
private sealed record Parameters(JellyfinSecrets Secrets, JellyfinServerInformation ServerInformation);
}

View File

@@ -3,4 +3,4 @@
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinAdminUserId(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IJellyfinBackgroundServiceRequest;
IScannerBackgroundServiceRequest;

View File

@@ -98,7 +98,7 @@ public class
.ToValidation<BaseError>("Jellyfin media source requires an api key");
}
private record ConnectionParameters(
private sealed record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection)
{

View File

@@ -1,6 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IJellyfinBackgroundServiceRequest;

View File

@@ -3,4 +3,4 @@
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinLibraries(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IJellyfinBackgroundServiceRequest;
IScannerBackgroundServiceRequest;

View File

@@ -104,7 +104,7 @@ public class
return Unit.Default;
}
private record ConnectionParameters(
private sealed record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection)
{

View File

@@ -3,18 +3,21 @@
namespace ErsatzTV.Application.Jellyfin;
public interface ISynchronizeJellyfinLibraryById : IRequest<Either<BaseError, string>>,
IJellyfinBackgroundServiceRequest
IScannerBackgroundServiceRequest
{
int JellyfinLibraryId { get; }
bool ForceScan { get; }
bool DeepScan { get; }
}
public record SynchronizeJellyfinLibraryByIdIfNeeded(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
{
public bool ForceScan => false;
public bool DeepScan => false;
}
public record ForceSynchronizeJellyfinLibraryById(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
public record ForceSynchronizeJellyfinLibraryById
(int JellyfinLibraryId, bool DeepScan) : ISynchronizeJellyfinLibraryById
{
public bool ForceScan => true;
}

View File

@@ -8,15 +8,15 @@ namespace ErsatzTV.Application.Jellyfin;
public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<SynchronizeJellyfinMediaSources,
Either<BaseError, List<JellyfinMediaSource>>>
{
private readonly ChannelWriter<IJellyfinBackgroundServiceRequest> _channel;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
public SynchronizeJellyfinMediaSourcesHandler(
IMediaSourceRepository mediaSourceRepository,
ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel)
{
_mediaSourceRepository = mediaSourceRepository;
_channel = channel;
_scannerWorkerChannel = scannerWorkerChannel;
}
public async Task<Either<BaseError, List<JellyfinMediaSource>>> Handle(
@@ -26,8 +26,10 @@ public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<Synchroniz
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
foreach (JellyfinMediaSource mediaSource in mediaSources)
{
await _channel.WriteAsync(new SynchronizeJellyfinAdminUserId(mediaSource.Id), cancellationToken);
await _channel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
await _scannerWorkerChannel.WriteAsync(
new SynchronizeJellyfinAdminUserId(mediaSource.Id),
cancellationToken);
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
}
return mediaSources;

View File

@@ -9,4 +9,4 @@ public record JellyfinLibraryViewModel(
LibraryMediaKind MediaKind,
bool ShouldSyncItems,
int MediaSourceId)
: LibraryViewModel("Jellyfin", Id, Name, MediaKind, MediaSourceId);
: LibraryViewModel("Jellyfin", Id, Name, MediaKind, MediaSourceId, string.Empty);

View File

@@ -60,7 +60,7 @@ public class GetJellyfinConnectionParametersHandler : IRequestHandler<GetJellyfi
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
}
private record ConnectionParameters(
private sealed record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection);
}

View File

@@ -0,0 +1,180 @@
using System.Runtime.InteropServices;
using System.Threading.Channels;
using CliWrap;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Serilog;
using Serilog.Events;
using Serilog.Formatting.Compact.Reader;
namespace ErsatzTV.Application.Libraries;
public abstract class CallLibraryScannerHandler<TRequest>
{
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediator _mediator;
private readonly IRuntimeInfo _runtimeInfo;
private string _libraryName;
protected CallLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
{
_dbContextFactory = dbContextFactory;
_configElementRepository = configElementRepository;
_channel = channel;
_mediator = mediator;
_runtimeInfo = runtimeInfo;
}
protected async Task<Either<BaseError, string>> PerformScan(
string scanner,
List<string> arguments,
CancellationToken cancellationToken)
{
try
{
using var forcefulCts = new CancellationTokenSource();
await using CancellationTokenRegistration link = cancellationToken.Register(
() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10))
);
CommandResult process = await Cli.Wrap(scanner)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.WithStandardErrorPipe(PipeTarget.ToDelegate(ProcessLogOutput))
.WithStandardOutputPipe(PipeTarget.ToDelegate(ProcessProgressOutput))
.ExecuteAsync(forcefulCts.Token, cancellationToken);
if (process.ExitCode != 0)
{
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
}
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
return _libraryName ?? string.Empty;
}
private static void ProcessLogOutput(string s)
{
if (!string.IsNullOrWhiteSpace(s))
{
try
{
// make a new log event to force using local time
// because the compact json writer used by the scanner
// writes in UTC
LogEvent logEvent = LogEventReader.ReadFromString(s);
Log.Write(
new LogEvent(
logEvent.Timestamp.ToLocalTime(),
logEvent.Level,
logEvent.Exception,
logEvent.MessageTemplate,
logEvent.Properties.Map(pair => new LogEventProperty(pair.Key, pair.Value))));
}
catch
{
Console.WriteLine(s);
}
}
}
private async Task ProcessProgressOutput(string s)
{
if (!string.IsNullOrWhiteSpace(s))
{
try
{
ScannerProgressUpdate progressUpdate = JsonConvert.DeserializeObject<ScannerProgressUpdate>(s);
if (progressUpdate != null)
{
if (!string.IsNullOrWhiteSpace(progressUpdate.LibraryName))
{
_libraryName = progressUpdate.LibraryName;
}
if (progressUpdate.PercentComplete is not null)
{
var progress = new LibraryScanProgress(
progressUpdate.LibraryId,
progressUpdate.PercentComplete.Value);
await _mediator.Publish(progress);
}
if (progressUpdate.ItemsToReindex.Length > 0)
{
var reindex = new ReindexMediaItems(progressUpdate.ItemsToReindex);
await _channel.WriteAsync(reindex);
}
if (progressUpdate.ItemsToRemove.Length > 0)
{
var remove = new RemoveMediaItems(progressUpdate.ItemsToRemove);
await _channel.WriteAsync(remove);
}
}
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Unable to process scanner progress update");
}
}
}
protected abstract Task<DateTimeOffset> GetLastScan(TvContext dbContext, TRequest request);
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
protected async Task<Validation<BaseError, string>> Validate(TRequest request)
{
int libraryRefreshInterval = await _configElementRepository
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.IfNoneAsync(0);
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
DateTimeOffset lastScan = await GetLastScan(dbContext, request);
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
{
return new ScanIsNotRequired();
}
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
? "ErsatzTV.Scanner.exe"
: "ErsatzTV.Scanner";
string processFileName = Environment.ProcessPath ?? string.Empty;
if (!string.IsNullOrWhiteSpace(processFileName))
{
string localFileName = Path.Combine(Path.GetDirectoryName(processFileName) ?? string.Empty, executable);
if (File.Exists(localFileName))
{
return localFileName;
}
}
return BaseError.New("Unable to locate ErsatzTV.Scanner executable");
}
}

View File

@@ -14,14 +14,14 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
public CreateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
IEntityLocker entityLocker,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_scannerWorkerChannel = scannerWorkerChannel;
_entityLocker = entityLocker;
_dbContextFactory = dbContextFactory;
}
@@ -32,7 +32,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
return await LanguageExtensions.Apply(validation, localLibrary => PersistLocalLibrary(dbContext, localLibrary));
}
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
@@ -44,7 +44,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
if (_entityLocker.LockLibrary(localLibrary.Id))
{
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
}
return ProjectToViewModel(localLibrary);

View File

@@ -39,7 +39,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => MovePath(dbContext, parameters));
return await LanguageExtensions.Apply(validation, parameters => MovePath(dbContext, parameters));
}
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
@@ -94,7 +94,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
.SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId)
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist"));
private async Task<string> GetPath(TvContext dbContext, MediaItem mediaItem) =>
private static async Task<string> GetPath(TvContext dbContext, MediaItem mediaItem) =>
mediaItem switch
{
Movie => await dbContext.Connection.QuerySingleAsync<string>(
@@ -115,5 +115,5 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
_ => null
};
private record Parameters(LibraryPath LibraryPath, LocalLibrary Library);
private sealed record Parameters(LibraryPath LibraryPath, LocalLibrary Library);
}

View File

@@ -16,16 +16,16 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
private readonly ISearchIndex _searchIndex;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
IEntityLocker entityLocker,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_scannerWorkerChannel = scannerWorkerChannel;
_entityLocker = entityLocker;
_searchIndex = searchIndex;
_dbContextFactory = dbContextFactory;
@@ -70,7 +70,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
if ((toAdd.Count > 0 || toRemove.Count > 0) && _entityLocker.LockLibrary(existing.Id))
{
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
}
return ProjectToViewModel(existing);
@@ -110,5 +110,5 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.ToUpperInvariant();
private record Parameters(LocalLibrary Existing, LocalLibrary Incoming);
private sealed record Parameters(LocalLibrary Existing, LocalLibrary Incoming);
}

View File

@@ -2,4 +2,10 @@
namespace ErsatzTV.Application.Libraries;
public record LibraryViewModel(string LibraryKind, int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId);
public record LibraryViewModel(
string LibraryKind,
int Id,
string Name,
LibraryMediaKind MediaKind,
int MediaSourceId,
string MediaSourceName);

View File

@@ -3,4 +3,4 @@
namespace ErsatzTV.Application.Libraries;
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId)
: LibraryViewModel("Local", Id, Name, MediaKind, MediaSourceId);
: LibraryViewModel("Local", Id, Name, MediaKind, MediaSourceId, string.Empty);

View File

@@ -10,7 +10,12 @@ internal static class Mapper
library switch
{
LocalLibrary l => ProjectToViewModel(l),
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind, p.MediaSourceId),
PlexLibrary p => new PlexLibraryViewModel(
p.Id,
p.Name,
p.MediaKind,
p.MediaSourceId,
GetServerName(p.MediaSource)),
JellyfinLibrary j => new JellyfinLibraryViewModel(
j.Id,
j.Name,
@@ -26,4 +31,11 @@ internal static class Mapper
public static LocalLibraryPathViewModel ProjectToViewModel(LibraryPath libraryPath) =>
new(libraryPath.Id, libraryPath.LibraryId, libraryPath.Path);
private static string GetServerName(MediaSource ms) =>
ms switch
{
PlexMediaSource pms => pms.ServerName,
_ => string.Empty
};
}

View File

@@ -2,5 +2,10 @@
namespace ErsatzTV.Application.Libraries;
public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId)
: LibraryViewModel("Plex", Id, Name, MediaKind, MediaSourceId);
public record PlexLibraryViewModel(
int Id,
string Name,
LibraryMediaKind MediaKind,
int MediaSourceId,
string MediaSourceName)
: LibraryViewModel("Plex", Id, Name, MediaKind, MediaSourceId, MediaSourceName);

Some files were not shown because too many files have changed in this diff Show More