Compare commits

...

157 Commits

Author SHA1 Message Date
Jason Dove
e363ab00bb prep for release v25.3.0 [no ci] 2025-07-24 20:43:49 -05:00
Jason Dove
dd9a6d5a06 add chapters to search index (#2199) 2025-07-24 21:03:58 +00:00
Jason Dove
fde05a0299 fix docker tag typo 2025-07-24 15:29:12 -05:00
Jason Dove
d3f8163580 use updated ersatztv-ffmpeg base images (#2198) 2025-07-24 20:27:06 +00:00
Jason Dove
07e4ff907f include docker-arm in unified image health check (#2196)
* include docker-arm in unified image health check

* update
2025-07-24 20:20:00 +00:00
Jason Dove
34874ac548 try to fix docker manifest step 2025-07-24 15:03:47 -05:00
Jason Dove
03e4c0207b use multi-platform docker images (#2195) 2025-07-24 19:56:46 +00:00
Jason Dove
b9faf87887 don't use arm64 runner for arm32 builds (#2194) 2025-07-24 03:21:21 +00:00
Jason Dove
2257d26173 fix some issues with live stream playback (#2193) 2025-07-24 01:58:32 +00:00
Jason Dove
8f6d208e31 use arm64 runners for arm builds (#2192)
* use arm64 runners for arm builds

* use matrix for linux builds on prs

* remove unused "kind"
2025-07-23 21:36:20 +00:00
Jason Dove
5ccea53131 fix media file path length for mysql (#2191) 2025-07-23 03:10:13 +00:00
Jason Dove
da6cb09658 fix tonemapping with amd vaapi (#2187)
* fix amd vaapi tonemap

* fixes
2025-07-22 17:35:06 +00:00
Jason Dove
260949893c fix some stream continuity issues (#2186) 2025-07-22 15:56:14 +00:00
Jason Dove
89b495dc90 qsv and pts fixes (#2184)
* try to fix qsv freezing

* update changelog

* fix unit tests
2025-07-21 19:00:07 +00:00
Jason Dove
74d6b32828 change how qsv is initialized on windows (#2183) 2025-07-21 17:23:30 +00:00
Jason Dove
626af6876b add start from beginning option to playback troubleshooting (#2182) 2025-07-21 16:17:16 +00:00
Jason Dove
2a05cc6e32 add remote stream is_live property (#2181) 2025-07-21 13:19:51 +00:00
Jason Dove
7a4c832156 add media card overflow menu (#2180)
* add media card overflow menu

* remove commented code
2025-07-21 11:00:39 +00:00
Jason Dove
011f16da9f fix variant selection for hls remote streams (#2177) 2025-07-20 18:24:04 +00:00
Jason Dove
79496e688b fix video stream selection for remote streams (#2176) 2025-07-20 17:31:21 +00:00
Jason Dove
5c43ae47b1 add basic remote stream library (#2175)
* initial remote stream library support; scanning seems to work ok

* flood schedule remote streams kind of works

* switch remote stream definitions to yaml files

* implement remote stream script playback

* update changelog
2025-07-20 16:10:32 +00:00
Jason Dove
c29788bc3f add movie nfo country to search index (#2173) 2025-07-19 21:56:13 +00:00
Jason Dove
3501e7c8d5 disable multiple mode select when not using playout mode multiple (#2172) 2025-07-19 20:42:05 +00:00
Jason Dove
867c88d8fc add trakt playlist option (#2171)
* add generate playlist option; add system playlists

* fix official lists; sync items to playlist
2025-07-19 16:56:25 +00:00
Jason Dove
70fbd4c746 add option to auto refresh trakt lists (#2169) 2025-07-19 14:19:07 +00:00
Jason Dove
1cbd48cea0 log nfo file name with nfo parsing errors (#2168) 2025-07-19 02:22:06 +00:00
Jason Dove
c953176cee change watermark width and margins to allow decimals (#2167) 2025-07-18 21:28:32 +00:00
Jason Dove
e0cef62969 fix block playout epg time zone (#2166) 2025-07-18 17:14:11 +00:00
Jason Dove
9e56f6552f support more multi-part grouping names (#2165) 2025-07-18 16:48:08 +00:00
Jason Dove
6a84c564d6 add multi-episode group size (#2164) 2025-07-18 14:46:00 +00:00
Jason Dove
54be3761dd add multiple mode to schedule items (#2163) 2025-07-18 14:03:56 +00:00
Jason Dove
cf6b9cf29a enable write-ahead logging on all sqlite databases (#2162) 2025-07-18 11:17:21 +00:00
Jason Dove
464c1e2ea8 fix bugs with playout mode multiple (#2160) 2025-07-18 01:53:19 +00:00
dependabot[bot]
107e8cfded Bump Jint and System.CommandLine (#2152)
Bumps Jint from 4.3.0 to 4.4.0
Bumps System.CommandLine from 2.0.0-beta5.25306.1 to 2.0.0-beta6.25358.103

---
updated-dependencies:
- dependency-name: Jint
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: System.CommandLine
  dependency-version: 2.0.0-beta6.25358.103
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-17 18:23:04 +00:00
Jason Dove
837f824660 include hardware info in troubleshooting archive (#2159)
* add cpu and gpu info to troubleshooting general

* include capabilities in troubleshooting archive
2025-07-17 16:23:57 +00:00
Jason Dove
223bdff8d6 playback troubleshooting improvements (#2157) 2025-07-17 14:53:11 +00:00
Jason Dove
578cdb1e14 add playback troubleshooting tool (#2155)
* support media info for more content types

* add playback troubleshooting page

* reorganize playback troubleshooting

* fix watermarks and delay

* update changelog
2025-07-17 03:51:36 +00:00
Jason Dove
848b88bd2d link ffmpeg health check to ersatztv-ffmpeg release (#2154)
* link ffmpeg health check to ersatztv-ffmpeg release

* bump windows ffmpeg to use the same version as linux
2025-07-16 17:13:25 +00:00
Jason Dove
b85571b159 allow uploading large watermarks (#2151) 2025-07-15 13:51:26 +00:00
Jason Dove
43e1cbd919 yaml playout watermarks (#2149) 2025-07-14 03:25:20 +00:00
Jason Dove
39b107eb0f add matched_points to filler expression (#2148) 2025-07-13 15:14:13 +00:00
Jason Dove
0ee62dbc7d fix recent regression to health check links (#2147) 2025-07-13 13:15:16 +00:00
Jason Dove
833bf3506a rework schedule items editor (#2146) 2025-07-13 00:57:27 +00:00
Jason Dove
cd75046348 rework playlist editor (#2145) 2025-07-12 23:17:47 +00:00
Jason Dove
448d29546c rework block editor (#2143) 2025-07-12 19:47:25 +00:00
Jason Dove
f2c49bd0fd rework alternate schedule and playout template editors (#2142)
* rework alternate schedules editor

* rework playout templates editor
2025-07-12 18:51:44 +00:00
Jason Dove
174c743cb7 more mobile layout updates (#2141)
* update trash layout

* cleanup block and yaml playout editors

* spacing cleanup

* rework multi-collection editor

* rework deco template editor

* rework template editor
2025-07-12 15:50:44 +00:00
Jason Dove
2a9f23cce6 update layout for group editors (#2140)
* update block group editor

* update playlist group editor

* update template group editor

* update deco group editor

* update deco template group editor

* update deco editor

* update logs layout

* update changelog
2025-07-12 13:45:50 +00:00
Jason Dove
451c534062 allow block items to disable watermarks (#2139)
* allow block items to disable watermarks

* fix test
2025-07-12 03:55:18 +00:00
Jason Dove
e16cb30ab1 add mid-roll filler expression (#2138) 2025-07-12 01:23:16 +00:00
Jason Dove
e0df454ac6 more layout updates for mobile (#2137)
* update trakt, filler, filler editor ui

* update schedules and playouts

* update playout editor

* update dependencies

* update yaml playout editor

* update path replacement editor
2025-07-11 21:08:37 +00:00
Jason Dove
e79a03b522 fix displaying local tv artwork in ui (#2136) 2025-07-11 14:44:16 +00:00
Jason Dove
1a09bb26d7 lots of mobile updates including detail pages (#2135)
* update artist page layout

* update season page layout

* rework collection view

* cleanup

* update collection editor
2025-07-11 14:21:39 +00:00
Jason Dove
ffd3e3604c rework many media pages (#2134)
* rework many list pages

* refactor

* rework movie details and season list
2025-07-11 02:47:30 +00:00
Jason Dove
7e40a809ff improve search results ui on mobile (#2133)
* show brief health check messages on mobile

* update libraries layout

* improve search results ui on mobile
2025-07-10 19:19:34 +00:00
Jason Dove
cecf18a7b5 improve mobile layout for some media source pages (#2132) 2025-07-10 17:28:51 +00:00
Jason Dove
7df33425fa improve health check display on mobile (#2131) 2025-07-10 15:23:10 +00:00
Jason Dove
5dfaa1a7ad improve mobile layout for some pages with tables (#2130) 2025-07-10 14:38:17 +00:00
Jason Dove
28a65e74bb use new form layout for local library editor (#2129) 2025-07-10 11:48:20 +00:00
Jason Dove
4a66f0ae43 use new form layout for watermark editor (#2127)
* use new form layout for watermark editor

* cleanup
2025-07-09 21:24:03 +00:00
Jason Dove
fb2466d32d vaapi tonemap fixes (#2125) 2025-07-07 20:58:45 +00:00
Jason Dove
beaaa62ed9 fix nvidia edge case with missing bit depth info (#2123)
* fix nvidia edge case with missing bit depth info

* revert docker-compose changes
2025-07-07 16:31:11 +00:00
Jason Dove
0b445f8cfd cache bust more css (#2119) 2025-07-07 02:50:34 +00:00
Jason Dove
7e30444857 dependencies and code cleanup (#2117)
* fix validation in new form layout

* pin mediatr to last oss version

* update dependencies

* cleanup code in core

* cleanup code in ffmpeg

* cleanup code in infra

* cleanup code in scanner

* cleanup code in application

* cleanup main code

* cleanup test code

* solution-wide code cleanup
2025-07-06 15:56:17 +00:00
Jason Dove
fa6a31b4fc use new form layout for schedule editor (#2116) 2025-07-06 11:32:11 +00:00
Jason Dove
b01ad9dbae restore incorrectly deleted file (#2114) 2025-07-05 17:40:00 +00:00
Jason Dove
d324967afa use new form layout for ffmpeg profile editor (#2113) 2025-07-05 13:07:01 +00:00
Jason Dove
aff4fb0deb use new form layout for channel editor (#2112) 2025-07-05 11:20:53 +00:00
Jason Dove
93afcd2f57 more settings updates (#2111)
* update logging settings layout

* update hdhomerun settings layout

* update scanner settings layout

* update playout settings layout

* update xmltv settings layout

* update changelog
2025-07-05 00:45:32 +00:00
Jason Dove
921a108684 ui updates (#2109)
* split settings into multiple pages

* show health check badge in nav menu

* undo transcoding test changes
2025-07-04 18:20:22 +00:00
Jason Dove
a6fa93d44e fix nvidia compatibility with ffmpeg 7.2+ (#2108)
* tweak random seed

* fix dotnet install in docker test

* fix nvidia compatibility with ffmpeg 7.2+
2025-07-03 15:08:18 +00:00
Jason Dove
a42234a7c3 update plex plot during deep scan (#2105) 2025-07-02 17:14:21 +00:00
Jason Dove
7c5137a4af remove some decode threading limits (#2103) 2025-07-02 00:34:07 +00:00
Jason Dove
5a9d27e196 make yaml playout count an expression (#2102) 2025-07-01 16:24:11 +00:00
Jason Dove
cd4a9c1d16 fix hdhr endpoint classification (#2101) 2025-07-01 15:13:16 +00:00
Jason Dove
f6249d9fa4 channel logo and watermark fixes (#2100)
* channel logo and watermark fixes

* update changelog
2025-07-01 13:40:30 +00:00
Jason Dove
e2ffa70529 support episodedetails and musicvideo as top-level other video nfo tags (#2098) 2025-07-01 02:59:18 +00:00
Jason Dove
3e07bc6136 fix history for playlists in yaml playouts (#2097) 2025-07-01 00:47:53 +00:00
Jason Dove
d6bfc2fd05 marathon playout history fixes (#2096) 2025-06-30 21:39:31 +00:00
Jason Dove
35116c64cd fix potential crash with recent marathon updates (#2095) 2025-06-30 19:23:31 +00:00
Jason Dove
037cee873f yaml marathon history (#2094)
* better playlist tests

* fix history for marathon content in yaml playouts
2025-06-30 19:05:09 +00:00
Jason Dove
cd28afcd91 dont reload appsettings.json at runtime (#2093)
* dont reload appsettings.json at runtime

* also disable here
2025-06-29 17:15:34 +00:00
Jason Dove
7457301d3e yaml playout skip items expression (#2092) 2025-06-29 14:31:56 +00:00
Jason Dove
7b7d378df7 run all mac builds on macos-14 (#2091) 2025-06-29 13:35:57 +00:00
Jason Dove
f6dcaf9108 fix qsv audio sync (#2090) 2025-06-29 03:49:04 +00:00
Jason Dove
6cc2f1de17 yaml playout improvements (#2088)
* add stop_before_end

* more yaml playout improvements
2025-06-28 16:27:58 +00:00
Jason Dove
c6ee41484e allow other videos and images to use the same folders (#2087) 2025-06-28 14:50:33 +00:00
Jason Dove
36d38c740f only scan plex networks on plex show libraries (#2086) 2025-06-28 12:56:49 +00:00
Jason Dove
0f795e4e2f add plex network metadata (#2085)
* initial plumbing

* scan for plex networks

* save network contents to db as tags

* eliminate network tag id churn

* add network and show_network to search index

* update last networks scan

* show networks on tv show page

* update changelog
2025-06-28 12:49:26 +00:00
Jason Dove
583cbf7b14 add channel active mode (#2083) 2025-06-27 21:19:26 +00:00
Jason Dove
27c701b936 fix software tonemap with nvidia (#2082) 2025-06-27 20:47:09 +00:00
Jason Dove
6e2c19d354 process missing language as und (#2081) 2025-06-27 14:45:30 +00:00
Jason Dove
4d83dc019c don't return stream selection when subtitles don't match (#2080) 2025-06-27 14:25:07 +00:00
Jason Dove
462057a4b1 prioritize stream selection by language (#2079) 2025-06-27 13:31:50 +00:00
Jason Dove
a04c72788f fix arm64 docker build (#2078) 2025-06-27 11:48:21 +00:00
Jason Dove
f94a440b62 stream selector improvements (#2077)
* add tests for audio blocklist and audio allowlist

* add subtitle allow list and block list

* add subtitle condition

* add audio condition

* cache bust mudblazor css
2025-06-27 11:40:06 +00:00
Jason Dove
f80069bb97 add custom channel stream selector system (#2076)
* add some basic channel stream selector models

* change windows ffmpeg url

* implement basic stream selection

* fixes
2025-06-27 03:00:59 +00:00
Jason Dove
c2769a08b4 stop building hwaccel-specific images (#2075)
* stop building hwaccel images

* update changelog
2025-06-26 17:26:11 +00:00
Jason Dove
e679fee940 update CHANGELOG for clarity [no ci] 2025-06-24 19:11:04 -05:00
Jason Dove
2271d5497b prep for release v25.2.0 [no ci] 2025-06-24 12:03:56 -05:00
Jason Dove
f71b6527c0 allow external channel logo urls (#2067) 2025-06-24 00:43:24 +00:00
Jason Dove
20d2fe71cd update macos app (#2066) 2025-06-23 21:19:42 +00:00
Jason Dove
1994f171d5 update windows launcher to respect ETV_UI_PORT (#2065) 2025-06-23 20:15:03 +00:00
Jason Dove
76f7c88375 add etv env vars to troubleshooting > general info (#2064) 2025-06-23 14:54:06 +00:00
Jason Dove
3805b9e48c update dependencies that require api migrations (#2063)
* migrate system.commandline to 2.0.0-beta5 api

* bump sixlabors.imagesharp

* migrate skiasharp to 3.x api
2025-06-23 13:49:24 +00:00
Jason Dove
9267edbcc9 fix scanning libraries (#2062) 2025-06-23 13:09:14 +00:00
Jason Dove
c4c164df6a detect cycles in yaml sequence definitions (#2060) 2025-06-23 02:55:35 +00:00
Jason Dove
b90463e3af detect cycles in smart collection queries (#2059) 2025-06-23 01:45:17 +00:00
Jason Dove
1fb27e3cfa fix nuget issues with transitive dependencies (#2058)
* try to fix nuget issues

* another attempt at surfacing warnings

* restore proper runtime

* remove old an unneeded dependencies

* upgrade transitive dep

* use newer dotnet in github
2025-06-22 20:22:37 +00:00
Jason Dove
06f233e5bd upgrade to dotnet 9 (#2056) 2025-06-22 19:04:39 +00:00
Jason Dove
9917774671 allow searching by smart collection (#2055) 2025-06-22 15:43:12 +00:00
Jason Dove
5be929da18 add collection (name) to search index (#2054) 2025-06-22 14:51:17 +00:00
Jason Dove
25f4fb22e5 yaml sequence improvements (#2053) 2025-06-21 19:28:41 +00:00
Jason Dove
b04b517f7b add dockerfile to run transcoding test suite (#2052) 2025-06-21 18:08:23 +00:00
Jason Dove
d756c0c7c0 properly filter vaapi driver health check to vaapi ffmpeg profile (#2050) 2025-06-21 11:36:02 +00:00
Jason Dove
20e5b8a11a add button to clone schedule item (#2048) 2025-06-20 21:52:45 +00:00
Jason Dove
5c8489cbed improve vaapi driver health check (#2047)
* improve vaapi driver health check

* fix duplicate check

* cleanup again
2025-06-20 19:16:52 +00:00
Jason Dove
7cfa298c72 fix xmltv grouping with post-roll filler (#2046) 2025-06-20 14:53:04 +00:00
Jason Dove
4b0faf4da1 unify hardware acceleration in docker (#2045) 2025-06-20 02:52:05 +00:00
Jason Dove
07cbf9936b fix shuffle in order and fill with group mode incompatibility (#2044) 2025-06-19 22:10:30 +00:00
Jason Dove
2138d6437c use noautoscale when also using hwaccel cuda (#2043) 2025-06-18 17:06:19 +00:00
Jason Dove
5b9601a57b maintain cuda pixel format throughout nvidia pipeline (#2042) 2025-06-18 02:59:10 +00:00
Jason Dove
aeda5050d3 nvidia decoder fixes (#2041)
* replace FluentAssertions with Shouldly

* fix song transcoding tests

* only specify hwaccel when hardware decode is required

* update changelog
2025-06-17 18:26:49 +00:00
Jason Dove
ea46a7a5ca add tonemap algorithm setting to ffmpeg profile (#2039) 2025-06-15 00:38:37 +00:00
Jason Dove
69a1e718df use ffmpeg 7.1.1 for nvidia docker (#2038) 2025-06-14 23:20:22 +00:00
Jason Dove
4a59dafe51 optimize tonemapping performance (#2037)
* add env var to disable vulkan

* tonemap after scaling

* vulkan tonemapping still needs to happen before scaling
2025-06-14 21:32:38 +00:00
Jason Dove
6b90da8982 add pad_vaapi filter (#2036) 2025-06-14 13:36:24 +00:00
Jason Dove
1184dc565c use ffmpeg 7.1.1 for base, arm, arm64 docker (#2035)
* use ffmpeg 7.1.1 for base, arm, arm64 docker

* keep newline
2025-06-14 11:16:30 +00:00
Jason Dove
d82ccf8fb5 use hardware-accelerated tonemapping with qsv (#2034)
* add tonemap for qsv

* update changelog
2025-06-14 01:19:35 +00:00
Jason Dove
4d83cc705f use ffmpeg 7.1.1 for vaapi docker (#2033) 2025-06-14 00:34:17 +00:00
Jason Dove
f80addacba use the clip algorithm for software tonemapping (#2032) 2025-06-13 20:12:31 +00:00
Jason Dove
18c2a816dc use ffmpeg 7.1.1 on windows (#2031) 2025-06-13 13:36:19 +00:00
Jason Dove
4f085c1950 fix detecting nvidia capabilities on blackwell gpus (#2030) 2025-06-13 03:14:22 +00:00
Jason Dove
dad0662fa6 use libplacebo to tonemap with nvidia/vulkan (#2029) 2025-06-12 20:49:06 +00:00
Jason Dove
dfdfa6f349 use hardware-accelerated tonemapping with vaapi (#2028)
* add tonemap_vaapi filter

* let vaapi pipeline handle hdr content

* use tonemap_opencl with vaapi

* update changelog
2025-06-12 16:13:43 +00:00
Jason Dove
5fe3e97b31 add software tonemap filter to support hdr content (#2027) 2025-06-12 14:47:59 +00:00
Jason Dove
da1cfab5f4 remove jellyfin admin user id requirement (#2025) 2025-06-09 13:33:47 +00:00
Jason Dove
6d152e4b4a use more accurate BANDWIDTH value in multi-variant playlist (#2023) 2025-06-07 20:45:07 +00:00
Jason Dove
2ca722523b improvements to plex connection management (#2020) 2025-05-31 18:44:55 +00:00
Jason Dove
d9a3496bf5 add fixed start time behavior option to schedules and schedule items (#2017) 2025-05-30 09:30:09 +00:00
Jason Dove
c8f5b51d93 use cache busting to avoid ui errors after upgrading mudblazor (#2016) 2025-05-30 00:22:49 +00:00
Jason Dove
956734ce39 globalization fixes (#2014)
* fix crashes caused by decimal separator

* improvements to playout reset ui

* remove code quality workflow
2025-05-26 16:32:25 +00:00
Jason Dove
e44a391f00 fix navigation when using ETV_BASE_URL (#2013) 2025-05-25 15:29:36 +00:00
Jason Dove
b43b66dd35 start to make ui minimally responsive (#2004) 2025-04-28 21:39:48 +00:00
Jason Dove
140a663da4 update dependencies (#2003)
* update dependencies

* more dependency updates
2025-04-28 17:20:45 +00:00
Jason Dove
876d79c11c update changelog [no ci] 2025-04-22 15:34:28 -05:00
Jason Dove
57a4480c3f try to push docker images to ghcr.io (#2000) 2025-04-22 20:24:14 +00:00
Jason Dove
8f1b57eb88 another attempt at fixing separate ports behind reverse proxy (#1994) 2025-04-14 18:53:25 +00:00
Jason Dove
70472ac84e add public port env vars; allow streaming through ui port (#1993) 2025-04-14 18:21:05 +00:00
Jason Dove
6aab8f53b8 fix changelog [no ci] 2025-04-14 11:57:01 -05:00
Jason Dove
b30b458574 allow ui and streaming to run on different ports (#1992)
* allow ui and streaming to run on different ports

* revert global.json change
2025-04-14 16:48:19 +00:00
Jason Dove
6fe6382485 add remote ip and user agent to http request logging (#1990) 2025-04-07 18:57:08 +00:00
Jason Dove
100ae0adda add api endpoint to empty trash (#1988) 2025-04-06 14:26:40 +00:00
Jason Dove
f0d5200843 bump image sharp lib to fix build (#1986) 2025-04-04 02:48:12 +00:00
Jason Dove
af36218cc2 add linux-musl-x64 artifact (#1985) 2025-04-04 02:45:09 +00:00
Jason Dove
eca62e8bec fix error synchronizing collections from plex server that has zero collections (#1964) 2025-01-15 21:42:22 -06:00
Jason Dove
03d4ab8c72 no longer mark releases as pre-release [no ci] 2025-01-10 12:26:53 -06:00
895 changed files with 337813 additions and 15261 deletions

View File

@@ -3,10 +3,11 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2024.1.1",
"version": "2025.1.4",
"commands": [
"jb"
]
],
"rollForward": false
}
}
}

View File

@@ -33,10 +33,10 @@ jobs:
strategy:
matrix:
include:
- os: macos-13
- os: macos-14
kind: macOS
target: osx-x64
- os: macos-13
- os: macos-14
kind: macOS
target: osx-arm64
steps:
@@ -48,6 +48,8 @@ jobs:
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -71,8 +73,8 @@ jobs:
shell: bash
run: |
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Bundle
shell: bash
@@ -130,7 +132,7 @@ jobs:
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: true
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: |
${{ env.RELEASE_NAME }}.dmg
@@ -148,8 +150,11 @@ jobs:
target: linux-x64
- os: ubuntu-latest
kind: linux
target: linux-arm
target: linux-musl-x64
- os: ubuntu-latest
kind: linux
target: linux-arm
- os: ubuntu-24.04-arm
kind: linux
target: linux-arm64
- os: windows-latest
@@ -163,6 +168,8 @@ jobs:
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -175,7 +182,7 @@ jobs:
id: downloadffmpeg
name: Download ffmpeg
with:
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/6.1-working-cuvid/ffmpeg-6.1-working-cuvid.7z"
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-56-gc2184b65d2-win64-gpl-7.1.zip"
target: ffmpeg/
- name: Build
@@ -187,8 +194,8 @@ jobs:
# Build everything
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
mkdir "$release_name"
mv scanner/* "$release_name/"
mv main/* "$release_name/"
@@ -230,7 +237,7 @@ jobs:
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: true
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: |
${{ env.RELEASE_NAME }}.zip

View File

@@ -1,23 +0,0 @@
name: Qodana
on:
workflow_dispatch:
push:
branches:
- main
jobs:
qodana:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
fetch-depth: 0 # a full history is required for pull request analysis
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2024.1
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: Build & Publish to Docker Hub
name: Build & Publish to Docker Hub
on:
workflow_call:
inputs:
@@ -22,31 +22,29 @@ on:
jobs:
build_and_push:
name: Build & Publish
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- name: base
- name: amd64
os: ubuntu-latest
path: ''
suffix: ''
qemu: false
- name: nvidia
path: 'nvidia/'
suffix: '-nvidia'
qemu: false
- name: vaapi
path: 'vaapi/'
suffix: '-vaapi'
suffix: '-amd64'
qemu: false
platform: 'linux/amd64'
- name: arm32v7
os: ubuntu-latest
path: 'arm32v7/'
suffix: '-arm'
qemu: true
platform: 'linux/arm/v7'
- name: arm64
os: ubuntu-24.04-arm
path: 'arm64/'
suffix: '-arm64'
qemu: true
platform: 'linux/arm64'
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -67,19 +65,12 @@ jobs:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Build and push
uses: docker/build-push-action@v5
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
@@ -88,25 +79,53 @@ jobs:
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
platforms: 'linux/arm64'
provenance: false
platforms: ${{ matrix.platform }}
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm64' }}
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
- name: Build and push
uses: docker/build-push-action@v5
merge_manifests:
name: Merge Manifests
runs-on: ubuntu-latest
needs: build_and_push
steps:
- name: Login to DockerHub
uses: docker/login-action@v3
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
platforms: 'linux/arm/v7'
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm32v7' }}
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push manifests
run: |
docker manifest create jasongdove/ersatztv:${{ inputs.base_version }} \
jasongdove/ersatztv:${{ inputs.base_version }}-amd64 \
jasongdove/ersatztv:${{ inputs.base_version }}-arm64 \
jasongdove/ersatztv:${{ inputs.base_version }}-arm
docker manifest push jasongdove/ersatztv:${{ inputs.base_version }}
docker manifest create jasongdove/ersatztv:${{ inputs.tag_version }} \
jasongdove/ersatztv:${{ inputs.tag_version }}-amd64 \
jasongdove/ersatztv:${{ inputs.tag_version }}-arm64 \
jasongdove/ersatztv:${{ inputs.tag_version }}-arm
docker manifest push jasongdove/ersatztv:${{ inputs.tag_version }}
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }} \
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}-amd64 \
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}-arm64 \
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}-arm
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }} \
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}-amd64 \
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}-arm64 \
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}-arm
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}

View File

@@ -10,6 +10,8 @@ jobs:
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -31,30 +33,43 @@ jobs:
cd ErsatzTV-Windows
cargo build --release --all-features
build_and_test_linux:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: linux-x64
- os: ubuntu-latest
target: linux-musl-x64
- os: ubuntu-latest
target: linux-arm
- os: ubuntu-24.04-arm
target: linux-arm64
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
run: dotnet restore -p:RestoreEnablePackagePruning=true -r "${{ matrix.target }}"
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore
run: dotnet build ErsatzTV/ErsatzTV.csproj --runtime "${{ matrix.target }}" --configuration Release --no-restore && dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
build_and_test_mac:
runs-on: macos-13
runs-on: macos-14
steps:
- name: Get the sources
uses: actions/checkout@v4
@@ -64,6 +79,8 @@ jobs:
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear

1
.gitignore vendored
View File

@@ -40,6 +40,7 @@ msbuild.wrn
core
scripts/generate-api-sdk/swagger.json
scripts/download-test-content.sh
docker-compose.override.yml

View File

@@ -5,6 +5,253 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [25.2.0] - 2025-07-24
### Added
- Add new channel stream (audio and subtitle) selector system
- Channel editor has a new field `Stream Selector Mode`
- `Default` maintains existing behavior
- `Custom` uses a YAML config file
- The YAML config contains a prioritized list of stream selector "items" (audio and subtitle criteria pairs)
- The items are tested against the media from top to bottom, and when (at least) a matching audio track is found, stream selection occurs
- As an example, the custom stream selector config can specify (in priority order):
- english audio (and disable subtitles)
- any other audio (and english subtitles, if they exist)
- Criteria can include
- Stream language
- Stream title (allowed title and/or blocked title)
- Stream condition, which is an expression that can use
- `id` (index)
- `title`
- `lang`
- `default`
- `forced`
- `sdh` (subtitle only)
- `external` (subtitle only)
- `codec`
- `channels` (audio only)
- An example subtitle condition: `lang like 'en%' and external`
- An example audio condition: `title like '%movie%' and channels > 2`
- Add new channel setting `Active Mode`
- `Active` - default value, channel streams as normal and has normal visibility
- `Hidden` - channel streams as normal and is hidden from M3U/XMLTV/HDHR
- `Inactive` - channel cannot stream (will 404) and is hidden from M3U/XMLTV/HDHR
- Synchronize Plex "network" metadata for Plex show libraries
- Shows will have new `network` search field
- Episodes will have new `show_network` search field
- YAML playout: add `stop_before_end` setting to `pad_until` and `duration` instructions
- When `stop_before_end: false`, content can run over the desired time before executing the next instruction
- YAML playout: add `offline_tail` setting to `pad_until` instruction
- This can be used to stop primary content before the desired time (`stop_before_end: true` and `offline_tail: false`)
- You can then have a second `pad_until` with the same target time and different content
- YAML playout: make `tomorrow` an expression on `pad_until` instruction
- `true` and `false` still work as normal
- The current time (as a decimal) can also be used in the expression, e.g. `now > 23`
- `now = hours + minutes / 60.0 + seconds / 3600.0`
- So `10:30 AM` would be `10.5`, `10:45 PM` would be `22.75`, etc
- YAML playout: make `skip_items` an expression
- The following parameters can be used:
- `count`: the total number of items in the content
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will start in the middle of the content
- `random` will start at a random point in the content
- `2` (similar to before this change) will skip the first two items in the content
- YAML playout: make `count` an expression
- The following parameters can be used:
- `count`: the total number of items in the content
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the content
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
- YAML playout: add `disable_watermarks` property to all content instructions
- This property defaults to `false` (meaning watermarks are allowed by default)
- Setting to `true` will prevent watermarks from ever appearing over the content
- YAML playout: add `watermark` instruction
- With value of `true` and `name` property, will override the watermark in the playout to the watermark with the provided name
- With value of `false`, will restore default watermark value (channel watermark, global watermark)
- Show health check warning and error badges in nav menu
- Add `Expression` for mid-roll filler to allow custom logic for using or skipping chapter markers
- The following parameters can be used:
- `total_points`: total number of potential mid-roll points
- `matched_points`: number of mid-roll points that have already matched the expression
- `total_duration`: total duration of the content, in seconds
- `total_progress`: normalized position from 0 to 1
- `last_mid_filler`: seconds since last mid-roll filler
- `remaining_duration`: duration of the content after this mid-roll point, in seconds
- `point`: the position of the mid-roll point, in seconds
- `num`: the mid-roll point number, starting with 1
- Add `Disable Watermarks` checkbox to block items
- Block items that have this checked will never display a watermark, even with Deco set to override watermark
- Add `ETV_MAXIMUM_UPLOAD_MB` environment variable to allow uploading large watermarks
- Default value is 10
- Update ffmpeg health check to link to ErsatzTV-FFmpeg release that contains binaries for win64, linux64, linuxarm64
- Add `Playback Troubleshooting` page
- This tool lets you play specific content without needing a test channel or schedule
- You can specify
- The media item id (found in ETV media info, and ETV movie URLs)
- The ffmpeg profile to use
- The watermark to use (if any)
- Clicking `Play` will play up to 30 seconds of the specified content using the desired settings
- Clicking `Download Results` will generate a zip archive containing:
- The FFmpeg report of the playback attempt
- The media info for the content
- The `Troubleshooting` > `General` output
- Support `(Part [english number])` name suffixes for multi-part episode grouping, for example:
- `Awesome Episode (Part One)`
- `Better Episode (Part Two)`
- `Not So Great (Part Three)`
- Add Trakt List option `Auto Refresh` to automatically update list from trakt.tv once each day
- Add Trakt List option `Generate Playlist` to automatically generate ETV Playlist from matched Trakt List items
- Read `country` field from movie NFO files and include in search index as `country`
- Add *experimental* and *incomplete* `Remote Stream` library kind
- Remote Stream libraries have fallback metadata added like Other Video libraries (every folder is a tag)
- Remote Stream library items consist of YAML (`.yml`) files with the following fields
- `url`: the URL of the content that can be played directly by ffmpeg
- `script`: the process name and arguments for a command that will output content to stdout
- `is_live`: *required* property that indicates whether the remote stream contains live content
- When this is set to `true`, ETV cannot work ahead on transcoding this item, which is a necessary tradeoff for supporting live content
- When this is set to `false`, ETV will treat the stream as VOD and attempt to work ahead on transcoding like any other local item
- This *will* cause errors when the content is actually live, so it's important to configure this correctly
- `duration`: when the content is live and does not have duration metadata, this must be provided to allow scheduling
- The remote stream definition (YAML file) may provide either a `url` or a `script`
- If both are provided, `url` will be used
- Include number of chapters in search index as `chapters`
### Changed
- Allow `Other Video` libraries and `Image` libraries to use the same folders
- Try to mitigate inotify limit error by disabling automatic reloading of `appsettings.json` config files
- Support `movie`, `musicvideo` and `episodedetails` top-level tags in other video NFO files
- Note that no change has been made to the metadata tags that are actually parsed, but this should help with various types of content
- Remove some limits on multithreading that are no longer needed with latest ffmpeg
- Mixed transcoding (software decode, hardware filters/encode) can now use multiple decode threads
- Split main `Settings` page into multiple pages
- Update UI layout on all pages to be less cramped and to work better on mobile
- Add CPU and Video Controller info to `Troubleshooting` > `General` output
- Enable write-ahead logging (WAL) mode on SQLite databases
- Add `Multiple Mode` option to schedule items editor and remove support for count values of zero
- `Count`: same behavior as before, requires a number of media items to play and will always schedule the same number
- `Collection Size`: similar to count of zero before, will play all media items from the collection before continuing to the next schedule item
- `Playlist Item Size`: will play all media items from the current playlist item before continuing to the next schedule item
- `Multi-Episode Group Size`: will play all media items from the current multi-part episode group, or one ungrouped media item
- Change watermark width and margins to allow decimals
- Move `Add To Collection` button to overflow menu on all media cards, and add `Show Media Info` to overflow menu
- This allows showing media info for all media kinds
- Unify on a multi-platform base docker tag (`latest` and `develop`)
- `amd64`, `arm64`, `arm/v7` platforms are now all supported in the base docker tag
- Other docker platform tags are deprecated and will receive no new updates after the next release
- A health check has been added to notify users (on `-arm` or `-arm64` tags) of this change
### Fixed
- Fix QSV acceleration in docker with older Intel devices
- Fix HDR transcoding with NVIDIA accel for:
- All NVIDIA docker users
- Windows NVIDIA users who have set the `ETV_DISABLE_VULKAN` env var
- Fix audio sync issue with QSV acceleration
- YAML playout: fix history for marathon and playlist content
- This allows playouts to be extended correctly, instead of always resetting to the earliest item in each group
- Fix using channel External Logo URL as watermark
- Fix display of SVG channel logo and watermark in admin UI
- Existing SVG logos and watermarks will have to be re-uploaded to display properly in the admin UI
- This does not affect streaming at all; existing artwork still works fine for streaming
- Classify HDHR endpoints as streaming endpoints
- This allows these endpoints to be accessed through port `ETV_STREAMING_PORT` (default `8409`)
- This only matters if you configured `ETV_UI_PORT` to be a different value, which makes UI endpoints inaccessible on the streaming port
- Update Plex movie/other video plot ("summary") during library deep scan
- Fix compatibility with ffmpeg 7.2+ when using NVIDIA accel and 10-bit source content
- Fix some NVIDIA edge cases when media servers don't provide video bit depth information
- Fix VAAPI tonemap failure
- Fix green bars after VAAPI tonemap
- Fix bug where playout mode `Multiple` would ignore fixed start time
- Fix block playout EPG generation to use `XMLTV Time Zone` setting
- Fix adding "official" Trakt lists
- Fix searching for `collection` names with spaces or other special characters, e.g. `collection:"Movies - Action"`
- Fix QSV transcoding errors when scaling
- Fix QSV frame freezing in browser
- Fix some stream continuity issues, and some cases where audio sync is lost at transition
- Fix HDR transcoding with AMD VAAPI accel
- Allow paths longer than 255 characters in MySql databases
## [25.2.0] - 2025-06-24
### Added
- Add `linux-musl-x64` artifact for users running Alpine x64
- Add API endpoint to empty trash (POST to `/api/maintenance/empty_trash`)
- e.g. `curl -XPOST -d '' http://localhost:8409/api/maintenance/empty_trash`
- Add remote IP and user agent to HTTP request logging
- Add environment variables to allow ETV to run UI and streaming on separate ports
- `ETV_STREAMING_PORT`: port used for streaming requests, defaults to 8409
- `ETV_UI_PORT`: port used for admin UI, defaults to 8409
- Publish docker images to ghcr.io (`ghcr.io/ersatztv/ersatztv`)
- Add new option `Fixed Start Time Behavior` to Schedules and Schedule Items
- Schedules can set a default behavior for all items
- Schedule items can override this default behavior
- Possible values are:
- `Strict`: Always wait for the exact start time, even if that means waiting (adding unscheduled time) until the next day
- `Flexible`: Start scheduling immediately (do not wait) if waiting (adding unscheduled time) would go into the next day
- As an example, if the current scheduling time is 6:02 AM and the next schedule item has a fixed start time of 6:00 AM
- `Strict` will add nearly 24h (23:58) of unscheduled time so that it can start exactly at 6:00 AM the next day
- `Flexible` will NOT add unscheduled time, and will schedule its item at 6:02 AM (which may also affect the scheduling of later items)
- Add basic HDR transcoding support
- VAAPI may use hardware-accelerated tone mapping (when opencl accel is also available)
- NVIDIA may use hardware-accelerated tone mapping (when vulkan accel and libplacebo filter are also available)
- QSV may use hardware-accelerated tone mapping (when hardware decoding is used)
- In all other cases, HDR content will use a software pipeline
- The tonemap algorithm can be configured in the ffmpeg profile
- Use hardware-accelerated padding with VAAPI
- Add environment variable `ETV_DISABLE_VULKAN`
- Any non-empty value will disable use of Vulkan acceleration and force software tonemapping
- This may be needed with misbehaving NVIDIA drivers on Windows
- Add health check error when invalid VAAPI device and VAAPI driver combination is used in an active ffmpeg profile
- This makes it obvious when hardware acceleration will not work as configured
- Add button in schedule editor to clone schedule item
- Allow YAML playout sequence definitions to reference other sequences
- Cycles will be detected and logged, and sequences with cycles will prevent the playout from building
- Add `repeat` property to YAML sequence instruction
- This tells the playout builder how many times this sequence should repeat
- Omitting this value is the same as setting it to `1`
- Add `collection` (name) to search index for manual collections created within ETV
- Collections synchronized from media servers are still indexed as `tag`
- Allow searching by `smart_collection` (name)
- Quotes are *always* required around each collection name when using this feature
- e.g. `smart_collection:"one" OR smart_collection:"two"`
- Cycles will be detected and logged, and searches with cycles will not work as expected
- Add all `ETV_*` environment variables to Troubleshooting > General info
- Add `External Logo URL` field to channel editor
- Using external (public) logos should fix channel logo display for clients that don't proxy artwork (such as Plex)
- Users who have customized the XMLTV channel template `channel.sbntxt` will need to update their templates again
- This is because the templates require different logic for external URLs vs ETV-hosted URLs
### Changed
- Start to make UI minimally responsive (functional on smaller screens)
- Change how ETV determines which address to use for Plex connections
- The active Plex connection (address) will only be cached for 30 seconds
- When the connection is no longer cached, a ping will be sent to the last used address for Plex (the last address that had a successful ping)
- If the ping is successful, the address will be cached for another 30 seconds
- If the ping is not successful, all addresses will be checked again, and the first address to return a successful ping will be cached for 30 seconds
- Remove requirement to have Jellyfin admin user; user id is no longer required on requests to latest Jellyfin server
- Upgrade bundled ffmpeg on Windows from 6.1 to 7.1.1
- Upgrade VAAPI docker image Ubuntu base from 22 to 24; bundled ffmpeg from 6.1 to 7.1.1
- Upgrade NVIDIA docker image Ubuntu base from 20 to 24; bundled ffmpeg from 6.1 to 7.1.1
- Upgrade base, arm, arm64 docker images bundled ffmpeg from 6.1 to 7.1.1
- Unify all hardware acceleration methods in base docker images (`latest` and `develop`)
- VAAPI, QSV and NVIDIA are now all supported in the base docker image
- Other docker image tags are deprecated and will receive no new updates after the next release
- A health check has been added to notify users (on `-vaapi` or `-nvidia` tags) of this change
- Schedule items editor: show currently selected row using background color instead of font weight
### Fixed
- Fix error message about synchronizing Plex collections from a Plex server that has zero collections
- Fix navigation after form submission when using `ETV_BASE_URL` environment variable
- Fix UI crashes when channel numbers contain a period `.` in locales that have a different decimal separator (e.g. `,`)
- Fix playout detail table to only reload once when resetting a playout
- Fix date formatting in playout detail table on reload (will now respect browser's `Accept-Language` header)
- Use cache busting to avoid UI errors after upgrading the MudBlazor library
- Fix multi-variant playlist to report more accurate `BANDWIDTH` value based on ffmpeg profile
- Fix detecting NVIDIA capabilities on Blackwell GPUs
- Fix decoder selection in NVIDIA pipeline
- Prevent playback order `Shuffle In Order` from being used with `Fill With Group Mode` as they are incompatible
- Fix XMLTV items not grouping properly (guide mode: `Filler`) due to post-roll filler
## [25.1.0] - 2025-01-10
### Added
- Add `Reset All Playouts` button to top of playouts page
@@ -2173,7 +2420,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.8-beta...HEAD
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.3.0...HEAD
[25.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.2.0...v25.3.0
[25.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.1.0...v25.2.0
[25.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.8-beta...v25.1.0
[0.8.8-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.7-beta...v0.8.8-beta
[0.8.7-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.6-beta...v0.8.7-beta
[0.8.6-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.5-beta...v0.8.6-beta

View File

@@ -1,6 +1,7 @@
#![windows_subsystem = "windows"]
use special_folder::SpecialFolder;
use std::env;
use std::fs;
use std::os::windows::process::CommandExt;
use std::process::Child;
@@ -21,11 +22,16 @@ fn main() {
let (tx, rx) = mpsc::channel();
tray.add_menu_item("Launch Web UI", || {
let ui_port = env::var("ETV_UI_PORT")
.ok()
.and_then(|val| val.parse::<u16>().ok())
.unwrap_or(8409);
let _ = Command::new("cmd")
.creation_flags(CREATE_NO_WINDOW)
.arg("/C")
.arg("start")
.arg("http://localhost:8409")
.arg(format!("http://localhost:{}", ui_port))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())

View File

@@ -29,9 +29,10 @@ internal static class Mapper
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Map(lang => allCultures.Filter(ci => string.Equals(
ci.ThreeLetterISOLanguageName,
lang,
StringComparison.OrdinalIgnoreCase)))
.Flatten()
.Distinct()
.ToList();

View File

@@ -0,0 +1,17 @@
using System.Net;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record ArtworkContentTypeModel(string Path, string ContentType)
{
public static readonly ArtworkContentTypeModel None = new(string.Empty, string.Empty);
public bool IsExternalUrl => Artwork.IsExternalUrl(Path);
public bool HasContentType => !string.IsNullOrWhiteSpace(ContentType);
public string UrlWithContentType => string.IsNullOrWhiteSpace(ContentType)
? Path
: $"{Path}?contentType={WebUtility.UrlEncode(ContentType)}";
}

View File

@@ -1,6 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record GetArtwork(int Id) : IRequest<Either<BaseError, Artwork>>;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record GetArtwork(int Id) : IRequest<Either<BaseError, Artwork>>;

View File

@@ -6,24 +6,25 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Artworks;
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory) : IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Artwork>> Handle(
GetArtwork request,
GetArtwork request,
CancellationToken cancellationToken)
{
try {
try
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Artwork> artwork = await dbContext.Artwork
.AsNoTracking()
.SelectOneAsync(a => a.Id, a => a.Id == request.Id)
.MapT(Project);
return artwork.ToEither(BaseError.New("Artwork not found"));
}
catch (Exception ex)
{
@@ -31,12 +32,11 @@ public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory) :
}
}
private static Artwork Project(Artwork artwork)
{
return new Artwork {
private static Artwork Project(Artwork artwork) =>
new()
{
Id = artwork.Id,
Path = artwork.Path,
ArtworkKind = artwork.ArtworkKind
};
}
}

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using System.Net;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -10,7 +11,9 @@ public record ChannelViewModel(
string Group,
string Categories,
int FFmpegProfileId,
string Logo,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
@@ -22,7 +25,8 @@ public record ChannelViewModel(
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode)
ChannelSongVideoMode SongVideoMode,
ChannelActiveMode ActiveMode)
{
public string WebEncodedName => WebUtility.UrlEncode(Name);
}

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -9,7 +10,9 @@ public record CreateChannel(
string Group,
string Categories,
int FFmpegProfileId,
string Logo,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
@@ -20,4 +23,5 @@ public record CreateChannel(
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, CreateChannelResult>>;
ChannelSongVideoMode SongVideoMode,
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, CreateChannelResult>>;

View File

@@ -40,58 +40,69 @@ public class CreateChannelHandler(
await FFmpegProfileMustExist(dbContext, request),
await WatermarkMustExist(dbContext, request),
await FillerPresetMustExist(dbContext, request))
.Apply(
(
name,
number,
ffmpegProfileId,
watermarkId,
fillerPresetId) =>
.Apply((
name,
number,
ffmpegProfileId,
watermarkId,
fillerPresetId) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
artwork.Add(
new Artwork
{
Path = request.Logo,
ArtworkKind = ArtworkKind.Logo,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
logo = logo.Replace("iptv/logos/", string.Empty);
}
var channel = new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
ProgressMode = request.ProgressMode,
StreamingMode = request.StreamingMode,
Artwork = artwork,
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
PreferredAudioTitle = request.PreferredAudioTitle,
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
SongVideoMode = request.SongVideoMode
};
artwork.Add(
new Artwork
{
Path = logo,
ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType)
? request.Logo.ContentType
: null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
foreach (int id in watermarkId)
{
channel.WatermarkId = id;
}
var channel = new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
ProgressMode = request.ProgressMode,
StreamingMode = request.StreamingMode,
Artwork = artwork,
StreamSelectorMode = request.StreamSelectorMode,
StreamSelector = request.StreamSelector,
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
PreferredAudioTitle = request.PreferredAudioTitle,
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
SongVideoMode = request.SongVideoMode,
ActiveMode = request.ActiveMode
};
foreach (int id in fillerPresetId)
{
channel.FallbackFillerId = id;
}
foreach (int id in watermarkId)
{
channel.WatermarkId = id;
}
return channel;
});
foreach (int id in fillerPresetId)
{
channel.FallbackFillerId = id;
}
return channel;
});
private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
createChannel.NotEmpty(c => c.Name)
@@ -158,8 +169,7 @@ public class CreateChannelHandler(
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.FallbackFillerId))
.Map(
o => o.ToValidation<BaseError>(
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
.Map(o => o.ToValidation<BaseError>(
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
}
}

View File

@@ -49,6 +49,18 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int inactiveCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ActiveMode != ChannelActiveMode.Active)
.CountAsync(cancellationToken);
if (inactiveCount > 0)
{
File.Delete(targetFile);
return;
}
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
@@ -85,8 +97,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
@@ -244,7 +254,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
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);
}
@@ -287,24 +296,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.Tail or FillerKind.Fallback or FillerKind.DecoDefault))
or FillerKind.PostRoll or FillerKind.Tail
or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
int customShowId = -1;
if (displayItem.MediaItem is Episode ep)
{
customShowId = ep.Season.ShowId;
}
bool isSameCustomShow = hasCustomTitle;
for (int x = j; x <= finishIndex; x++)
{
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
customShowId == e.Season.ShowId;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
@@ -349,7 +346,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
}
}
private static async Task WriteBlockPlayoutXml(
private async Task WriteBlockPlayoutXml(
RefreshChannelData request,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
@@ -361,6 +358,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
XmlMinifier minifier,
XmlWriter xml)
{
XmltvTimeZone xmltvTimeZone = await _configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone)
.IfNoneAsync(XmltvTimeZone.Local);
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
{
@@ -376,7 +377,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime();
DateTimeOffset currentStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)

View File

@@ -3,6 +3,7 @@ using System.Net;
using System.Xml;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure.Data;
@@ -77,6 +78,9 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken))
{
bool hasLogo = !string.IsNullOrWhiteSpace(channel.ArtworkPath);
bool hasExternalLogo = hasLogo && Artwork.IsExternalUrl(channel.ArtworkPath);
var data = new
{
ChannelId = ChannelIdentifier.FromNumber(channel.Number),
@@ -84,7 +88,8 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
ChannelNumber = channel.Number,
ChannelName = channel.Name,
ChannelCategories = GetCategories(channel.Categories),
ChannelHasArtwork = !string.IsNullOrWhiteSpace(channel.ArtworkPath),
ChannelHasExternalArtwork = hasExternalLogo,
ChannelHasArtwork = hasLogo,
ChannelArtworkPath = channel.ArtworkPath,
ChannelNameEncoded = WebUtility.UrlEncode(channel.Name)
};
@@ -113,7 +118,7 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
from Channel C
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
where C.Id in (select ChannelId from Playout)
where C.Id in (select ChannelId from Playout) and C.ActiveMode = 0
order by CAST(C.Number as double)";
// TODO: this needs to be fixed for sqlite/mariadb

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -10,7 +11,9 @@ public record UpdateChannel(
string Group,
string Categories,
int FFmpegProfileId,
string Logo,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
@@ -21,4 +24,5 @@ public record UpdateChannel(
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, ChannelViewModel>>;
ChannelSongVideoMode SongVideoMode,
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, ChannelViewModel>>;

View File

@@ -1,5 +1,4 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
@@ -35,6 +34,8 @@ public class UpdateChannelHandler(
c.Group = update.Group;
c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId;
c.StreamSelectorMode = update.StreamSelectorMode;
c.StreamSelector = update.StreamSelector;
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
c.PreferredAudioTitle = update.PreferredAudioTitle;
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
@@ -42,30 +43,53 @@ public class UpdateChannelHandler(
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
c.SongVideoMode = update.SongVideoMode;
c.Artwork ??= new List<Artwork>();
c.ActiveMode = update.ActiveMode;
c.Artwork ??= [];
if (!string.IsNullOrWhiteSpace(update.Logo))
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
{
Option<Artwork> maybeLogo =
Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo);
string logo = update.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
}
maybeLogo.Match(
artwork =>
Option<Artwork> maybeLogo = c.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo).HeadOrNone();
foreach (Artwork artwork in maybeLogo)
{
artwork.Path = logo;
artwork.OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
? update.Logo.ContentType
: null;
artwork.DateUpdated = DateTime.UtcNow;
}
if (maybeLogo.IsNone)
{
var artwork = new Artwork
{
artwork.Path = update.Logo;
artwork.DateUpdated = DateTime.UtcNow;
},
() =>
{
var artwork = new Artwork
{
Path = update.Logo,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
ArtworkKind = ArtworkKind.Logo
};
c.Artwork.Add(artwork);
});
Path = logo,
OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
? update.Logo.ContentType
: null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
ArtworkKind = ArtworkKind.Logo
};
c.Artwork.Add(artwork);
}
}
else
{
await dbContext.Entry(c)
.Collection(channel => channel.Artwork)
.LoadAsync();
foreach (Artwork artwork in c.Artwork.Where(x => x.ArtworkKind is ArtworkKind.Logo).ToList())
{
c.Artwork.Remove(artwork);
dbContext.Artwork.Remove(artwork);
}
}
c.ProgressMode = update.ProgressMode;

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -14,6 +15,8 @@ internal static class Mapper
channel.Categories,
channel.FFmpegProfileId,
GetLogo(channel),
channel.StreamSelectorMode,
channel.StreamSelector,
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
channel.ProgressMode,
@@ -25,7 +28,8 @@ internal static class Mapper
channel.SubtitleMode,
channel.MusicVideoCreditsMode,
channel.MusicVideoCreditsTemplate,
channel.SongVideoMode);
channel.SongVideoMode,
channel.ActiveMode);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
@@ -39,9 +43,24 @@ internal static class Mapper
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
.Match(a => a.Path, string.Empty);
internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) =>
new(resolution.Height, resolution.Width, bitrate);
private static ArtworkContentTypeModel GetLogo(Channel channel)
{
Option<Artwork> maybeArtwork = channel.Artwork
.Where(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone();
foreach (Artwork artwork in maybeArtwork)
{
return artwork.IsExternalUrl()
? new ArtworkContentTypeModel(artwork.Path, string.Empty)
: new ArtworkContentTypeModel($"iptv/logos/{artwork.Path}", artwork.OriginalContentType);
}
return ArtworkContentTypeModel.None;
}
private static string GetStreamingMode(Channel channel) =>
channel.StreamingMode switch

View File

@@ -3,13 +3,10 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByIdHandler : IRequestHandler<GetChannelById, Option<ChannelViewModel>>
public class GetChannelByIdHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelById, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByIdHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
_channelRepository.GetChannel(request.Id)
channelRepository.GetChannel(request.Id)
.MapT(ProjectToViewModel);
}

View File

@@ -1,5 +1,7 @@
using System.Collections.Immutable;
using System.Text;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure.Data;
@@ -29,6 +31,12 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var inactiveChannelNumbers = dbContext.Channels
.Where(c => c.ActiveMode != ChannelActiveMode.Active)
.Select(c => c.Number)
.AsEnumerable()
.Select(n => $"{n}.xml")
.ToImmutableHashSet();
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_localFileSystem.FileExists(channelsFile))
@@ -60,6 +68,11 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
continue;
}
if (inactiveChannelNumbers.Contains(Path.GetFileName(fileName)))
{
continue;
}
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
channelDataFragment = channelDataFragment

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core.Hdhr;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Hdhr;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Channels;
@@ -11,5 +12,6 @@ public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<Li
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
.Map(channels => channels.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
.Map(channels => channels.Where(c => c.ActiveMode is ChannelActiveMode.Active)
.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
}

View File

@@ -14,20 +14,24 @@ 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,
request.BaseUrl,
channels,
request.UserAgent,
request.AccessToken));
.Map(channels => new ChannelPlaylist(
request.Scheme,
request.Host,
request.BaseUrl,
channels,
request.UserAgent,
request.AccessToken));
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{
var result = new List<Channel>();
foreach (Channel channel in channels)
{
if (channel.ActiveMode is not ChannelActiveMode.Active)
{
continue;
}
switch (mode.ToLowerInvariant())
{
case "segmenter":

View File

@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelResolution(string ChannelNumber) : IRequest<Option<ResolutionViewModel>>;

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelResolutionAndBitrate(string ChannelNumber) : IRequest<Option<ResolutionAndBitrateViewModel>>;

View File

@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelResolutionHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelResolution, Option<ResolutionViewModel>>
public class GetChannelResolutionAndBitrateHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelResolutionAndBitrate, Option<ResolutionAndBitrateViewModel>>
{
public async Task<Option<ResolutionViewModel>> Handle(
GetChannelResolution request,
public async Task<Option<ResolutionAndBitrateViewModel>> Handle(
GetChannelResolutionAndBitrate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -20,6 +20,8 @@ public class GetChannelResolutionHandler(IDbContextFactory<TvContext> dbContextF
.ThenInclude(ff => ff.Resolution)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
return maybeChannel.Map(c => Mapper.ProjectToViewModel(c.FFmpegProfile.Resolution));
return maybeChannel.Map(c => Mapper.ProjectToViewModel(
c.FFmpegProfile.Resolution,
(int)((c.FFmpegProfile.VideoBitrate * 1000 + c.FFmpegProfile.AudioBitrate * 1000) * 1.2)));
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelStreamSelectors : IRequest<List<string>>;

View File

@@ -0,0 +1,14 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Application.Channels;
public class GetChannelStreamSelectorsHandler(ILocalFileSystem localFileSystem)
: IRequestHandler<GetChannelStreamSelectors, List<string>>
{
public Task<List<string>> Handle(GetChannelStreamSelectors request, CancellationToken cancellationToken) =>
localFileSystem.ListFiles(FileSystemLayout.ChannelStreamSelectorsFolder)
.Map(Path.GetFileName)
.ToList()
.AsTask();
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record ResolutionAndBitrateViewModel(int Height, int Width, int Bitrate);

View File

@@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateGeneralSettings(GeneralSettingsViewModel GeneralSettings) : IRequest<Either<BaseError, Unit>>;

View File

@@ -16,10 +16,9 @@ public class UpdateLibraryRefreshIntervalHandler :
UpdateLibraryRefreshInterval request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(
_ => _configElementRepository.Upsert(
ConfigElementKey.LibraryRefreshInterval,
request.LibraryRefreshInterval))
.MapT(_ => _configElementRepository.Upsert(
ConfigElementKey.LibraryRefreshInterval,
request.LibraryRefreshInterval))
.Bind(v => v.ToEitherAsync());
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateLoggingSettings(LoggingSettingsViewModel LoggingSettings) : IRequest<Either<BaseError, Unit>>;

View File

@@ -4,12 +4,12 @@ using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSettings, Either<BaseError, Unit>>
public class UpdateLoggingSettingsHandler : IRequestHandler<UpdateLoggingSettings, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly LoggingLevelSwitches _loggingLevelSwitches;
public UpdateGeneralSettingsHandler(
public UpdateLoggingSettingsHandler(
LoggingLevelSwitches loggingLevelSwitches,
IConfigElementRepository configElementRepository)
{
@@ -18,33 +18,33 @@ public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSetting
}
public async Task<Either<BaseError, Unit>> Handle(
UpdateGeneralSettings request,
CancellationToken cancellationToken) => await ApplyUpdate(request.GeneralSettings);
UpdateLoggingSettings request,
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings);
private async Task<Unit> ApplyUpdate(GeneralSettingsViewModel generalSettings)
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings)
{
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.DefaultMinimumLogLevel);
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = generalSettings.DefaultMinimumLogLevel;
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel);
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = loggingSettings.DefaultMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelScanning,
generalSettings.ScanningMinimumLogLevel);
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = generalSettings.ScanningMinimumLogLevel;
loggingSettings.ScanningMinimumLogLevel);
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = loggingSettings.ScanningMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelScheduling,
generalSettings.SchedulingMinimumLogLevel);
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = generalSettings.SchedulingMinimumLogLevel;
loggingSettings.SchedulingMinimumLogLevel);
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = loggingSettings.SchedulingMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelStreaming,
generalSettings.StreamingMinimumLogLevel);
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = generalSettings.StreamingMinimumLogLevel;
loggingSettings.StreamingMinimumLogLevel);
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = loggingSettings.StreamingMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelHttp,
generalSettings.HttpMinimumLogLevel);
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = generalSettings.HttpMinimumLogLevel;
loggingSettings.HttpMinimumLogLevel);
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = loggingSettings.HttpMinimumLogLevel;
return Unit.Default;
}

View File

@@ -2,7 +2,7 @@ using Serilog.Events;
namespace ErsatzTV.Application.Configuration;
public class GeneralSettingsViewModel
public class LoggingSettingsViewModel
{
public LogEventLevel DefaultMinimumLogLevel { get; set; }
public LogEventLevel ScanningMinimumLogLevel { get; set; }

View File

@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Configuration;
public record GetGeneralSettings : IRequest<GeneralSettingsViewModel>;

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetLoggingSettings : IRequest<LoggingSettingsViewModel>;

View File

@@ -4,14 +4,14 @@ using Serilog.Events;
namespace ErsatzTV.Application.Configuration;
public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, GeneralSettingsViewModel>
public class GetLoggingSettingsHandler : IRequestHandler<GetLoggingSettings, LoggingSettingsViewModel>
{
private readonly IConfigElementRepository _configElementRepository;
public GetGeneralSettingsHandler(IConfigElementRepository configElementRepository) =>
public GetLoggingSettingsHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<GeneralSettingsViewModel> Handle(GetGeneralSettings request, CancellationToken cancellationToken)
public async Task<LoggingSettingsViewModel> Handle(GetLoggingSettings request, CancellationToken cancellationToken)
{
Option<LogEventLevel> maybeDefaultLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
@@ -28,7 +28,7 @@ public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, Gen
Option<LogEventLevel> maybeHttpLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelHttp);
return new GeneralSettingsViewModel
return new LoggingSettingsViewModel
{
DefaultMinimumLogLevel = await maybeDefaultLevel.IfNoneAsync(LogEventLevel.Information),
ScanningMinimumLogLevel = await maybeScanningLevel.IfNoneAsync(LogEventLevel.Information),

View File

@@ -26,7 +26,6 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby();
foreach (EmbyMediaSource mediaSource in mediaSources)
{
// await _channel.WriteAsync(new SynchronizeEmbyAdminUserId(mediaSource.Id), cancellationToken);
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
}

View File

@@ -43,7 +43,6 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
UpdateEmbyPathReplacements request) =>
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
.Map(
v => v.ToValidation<BaseError>(
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
.Map(v => v.ToValidation<BaseError>(
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
}

View File

@@ -54,9 +54,8 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
.Map(
v => v.ToValidation<BaseError>(
"Emby media source does not exist."));
.Map(v => v.ToValidation<BaseError>(
"Emby media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
EmbyMediaSource embyMediaSource)

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
@@ -9,25 +9,25 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.6.7" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.12.19">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
<PackageReference Include="Bugsnag" Version="4.0.0" />
<PackageReference Include="CliWrap" Version="3.9.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.7" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,5 +1,6 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=artists_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=artworks_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=channels_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=channels_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=configuration_005Ccommands/@EntryIndexedValue">True</s:Boolean>
@@ -44,5 +45,7 @@
<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/=troubleshooting_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=validators/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Cqueries/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -21,6 +21,7 @@ public record CreateFFmpegProfile(
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
int VideoBufferSize,
FFmpegProfileTonemapAlgorithm TonemapAlgorithm,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,

View File

@@ -42,33 +42,33 @@ public class CreateFFmpegProfileHandler :
TvContext dbContext,
CreateFFmpegProfile request) =>
(ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(dbContext, request))
.Apply(
(name, threadCount, resolutionId) => new FFmpegProfile
{
Name = name,
ThreadCount = threadCount,
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
VideoFormat = request.VideoFormat,
VideoProfile = request.VideoProfile,
VideoPreset = request.VideoPreset,
AllowBFrames = request.AllowBFrames,
BitDepth = request.BitDepth,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
DeinterlaceVideo = request.DeinterlaceVideo
});
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
{
Name = name,
ThreadCount = threadCount,
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
VideoFormat = request.VideoFormat,
VideoProfile = request.VideoProfile,
VideoPreset = request.VideoPreset,
AllowBFrames = request.AllowBFrames,
BitDepth = request.BitDepth,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
TonemapAlgorithm = request.TonemapAlgorithm,
AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
DeinterlaceVideo = request.DeinterlaceVideo
});
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.NotEmpty(x => x.Name)

View File

@@ -22,6 +22,7 @@ public record UpdateFFmpegProfile(
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
int VideoBufferSize,
FFmpegProfileTonemapAlgorithm TonemapAlgorithm,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,

View File

@@ -54,6 +54,7 @@ public class
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.TonemapAlgorithm = update.TonemapAlgorithm;
p.AudioFormat = update.AudioFormat;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;

View File

@@ -22,6 +22,7 @@ public record FFmpegProfileViewModel(
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
int VideoBufferSize,
FFmpegProfileTonemapAlgorithm TonemapAlgorithm,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,

View File

@@ -24,6 +24,7 @@ internal static class Mapper
profile.BitDepth,
profile.VideoBitrate,
profile.VideoBufferSize,
profile.TonemapAlgorithm,
profile.AudioFormat,
profile.AudioBitrate,
profile.AudioBufferSize,
@@ -54,6 +55,7 @@ internal static class Mapper
(int)ffmpegProfile.VideoFormat,
ffmpegProfile.VideoBitrate,
ffmpegProfile.VideoBufferSize,
(int)ffmpegProfile.TonemapAlgorithm,
(int)ffmpegProfile.AudioFormat,
ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize,

View File

@@ -16,5 +16,6 @@ public record CreateFillerPreset(
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId
int? SmartCollectionId,
string Expression
) : IRequest<Either<BaseError, Unit>>;

View File

@@ -36,7 +36,8 @@ public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Eit
CollectionId = request.CollectionId,
MediaItemId = request.MediaItemId,
MultiCollectionId = request.MultiCollectionId,
SmartCollectionId = request.SmartCollectionId
SmartCollectionId = request.SmartCollectionId,
Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null
};
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);

View File

@@ -17,5 +17,6 @@ public record UpdateFillerPreset(
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId
int? SmartCollectionId,
string Expression
) : IRequest<Either<BaseError, Unit>>;

View File

@@ -37,6 +37,7 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
existing.MediaItemId = request.MediaItemId;
existing.MultiCollectionId = request.MultiCollectionId;
existing.SmartCollectionId = request.SmartCollectionId;
existing.Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null;
await dbContext.SaveChangesAsync();

View File

@@ -16,4 +16,5 @@ public record FillerPresetViewModel(
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId);
int? SmartCollectionId,
string Expression);

View File

@@ -18,5 +18,6 @@ internal static class Mapper
fillerPreset.CollectionId,
fillerPreset.MediaItemId,
fillerPreset.MultiCollectionId,
fillerPreset.SmartCollectionId);
fillerPreset.SmartCollectionId,
fillerPreset.Expression);
}

View File

@@ -16,10 +16,9 @@ public class UpdateHDHRTunerCountHandler : IRequestHandler<UpdateHDHRTunerCount,
UpdateHDHRTunerCount request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(
_ => _configElementRepository.Upsert(
ConfigElementKey.HDHRTunerCount,
request.TunerCount.ToString(CultureInfo.InvariantCulture)))
.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

@@ -13,12 +13,11 @@ public class GetHDHRUUIDHandler : IRequestHandler<GetHDHRUUID, Guid>
public async Task<Guid> Handle(GetHDHRUUID request, CancellationToken cancellationToken)
{
Option<Guid> maybeGuid = await _configElementRepository.GetValue<Guid>(ConfigElementKey.HDHRUUID);
return await maybeGuid.IfNoneAsync(
async () =>
{
Guid guid = Guid.NewGuid();
await _configElementRepository.Upsert(ConfigElementKey.HDHRUUID, guid);
return guid;
});
return await maybeGuid.IfNoneAsync(async () =>
{
var guid = Guid.NewGuid();
await _configElementRepository.Upsert(ConfigElementKey.HDHRUUID, guid);
return guid;
});
}
}

View File

@@ -4,4 +4,5 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Images;
// ReSharper disable once SuggestBaseTypeForParameter
public record SaveArtworkToDisk(Stream Stream, ArtworkKind ArtworkKind) : IRequest<Either<BaseError, string>>;
public record SaveArtworkToDisk(Stream Stream, ArtworkKind ArtworkKind, string ContentType)
: IRequest<Either<BaseError, string>>;

View File

@@ -97,9 +97,8 @@ public class UpdateImageFolderDurationHandler(IDbContextFactory<TvContext> dbCon
// update all images in this folder
await dbContext.ImageMetadata
.Filter(
im => im.Image.MediaVersions.Any(
mv => mv.MediaFiles.Any(mf => mf.LibraryFolderId == currentFolder.Id)))
.Filter(im =>
im.Image.MediaVersions.Any(mv => mv.MediaFiles.Any(mf => mf.LibraryFolderId == currentFolder.Id)))
.ExecuteUpdateAsync(
setters => setters.SetProperty(im => im.DurationSeconds, effectiveDuration),
cancellationToken);

View File

@@ -3,5 +3,6 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Images;
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<
Either<BaseError, CachedImagePathViewModel>>;
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, string ContentType, int? MaxHeight = null)
: IRequest<
Either<BaseError, CachedImagePathViewModel>>;

View File

@@ -42,7 +42,7 @@ public class
{
try
{
MimeType mimeType;
string mimeType;
string cachePath = _imageCache.GetPathForImage(
request.FileName,
@@ -84,7 +84,7 @@ public class
File.Move(withExtension, cachePath);
mimeType = new MimeType("image/jpeg");
mimeType = "image/jpeg";
}
else
{
@@ -93,10 +93,12 @@ public class
}
else
{
mimeType = MimeTypes.GetMimeTypeFromFile(cachePath);
mimeType = !string.IsNullOrWhiteSpace(request.ContentType)
? request.ContentType
: MimeTypes.GetMimeTypeFromFile(cachePath).Name;
}
return new CachedImagePathViewModel(cachePath, mimeType.Name);
return new CachedImagePathViewModel(cachePath, mimeType);
}
catch (Exception ex)
{

View File

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

View File

@@ -1,107 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin;
public class
SynchronizeJellyfinAdminUserIdHandler : IRequestHandler<SynchronizeJellyfinAdminUserId,
Either<BaseError, Unit>>
{
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly ILogger<SynchronizeJellyfinAdminUserIdHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
public SynchronizeJellyfinAdminUserIdHandler(
IMemoryCache memoryCache,
IMediaSourceRepository mediaSourceRepository,
IJellyfinSecretStore jellyfinSecretStore,
IJellyfinApiClient jellyfinApiClient,
ILogger<SynchronizeJellyfinAdminUserIdHandler> logger)
{
_memoryCache = memoryCache;
_mediaSourceRepository = mediaSourceRepository;
_jellyfinSecretStore = jellyfinSecretStore;
_jellyfinApiClient = jellyfinApiClient;
_logger = logger;
}
public Task<Either<BaseError, Unit>> Handle(
SynchronizeJellyfinAdminUserId request,
CancellationToken cancellationToken) =>
Validate(request)
.Map(v => v.ToEither<ConnectionParameters>())
.BindT(PerformSync);
private async Task<Either<BaseError, Unit>> PerformSync(ConnectionParameters parameters)
{
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{parameters.JellyfinMediaSource.Id}", out string _))
{
return Unit.Default;
}
Either<BaseError, string> maybeUserId = await _jellyfinApiClient.GetAdminUserId(
parameters.ActiveConnection.Address,
parameters.ApiKey);
return await maybeUserId.Match(
userId =>
{
// _logger.LogDebug("Jellyfin admin user id is {UserId}", userId);
_memoryCache.Set($"jellyfin_admin_user_id.{parameters.JellyfinMediaSource.Id}", userId);
return Task.FromResult<Either<BaseError, Unit>>(Unit.Default);
},
async error =>
{
// clear api key if unable to sync with jellyfin
if (error.Value.Contains("Unauthorized"))
{
await _jellyfinSecretStore.SaveSecrets(
new JellyfinSecrets { Address = parameters.ActiveConnection.Address, ApiKey = null });
}
return Left<BaseError, Unit>(error);
});
}
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinAdminUserId request) =>
MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, JellyfinMediaSource>> MediaSourceMustExist(
SynchronizeJellyfinAdminUserId request) =>
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
JellyfinMediaSource jellyfinMediaSource)
{
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
ConnectionParameters connectionParameters)
{
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Jellyfin media source requires an api key");
}
private sealed record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
}

View File

@@ -26,9 +26,6 @@ public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<Synchroniz
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
foreach (JellyfinMediaSource mediaSource in mediaSources)
{
await _scannerWorkerChannel.WriteAsync(
new SynchronizeJellyfinAdminUserId(mediaSource.Id),
cancellationToken);
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
}

View File

@@ -43,7 +43,6 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
UpdateJellyfinPathReplacements request) =>
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
.Map(
v => v.ToValidation<BaseError>(
$"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."));
.Map(v => v.ToValidation<BaseError>(
$"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."));
}

View File

@@ -48,9 +48,8 @@ public class GetJellyfinConnectionParametersHandler : IRequestHandler<GetJellyfi
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist() =>
_mediaSourceRepository.GetAllJellyfin().Map(list => list.HeadOrNone())
.Map(
v => v.ToValidation<BaseError>(
"Jellyfin media source does not exist."));
.Map(v => v.ToValidation<BaseError>(
"Jellyfin media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
JellyfinMediaSource jellyfinMediaSource)

View File

@@ -54,9 +54,9 @@ public abstract class CallLibraryScannerHandler<TRequest>
{
using var forcefulCts = new CancellationTokenSource();
await using CancellationTokenRegistration link = cancellationToken.Register(
() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10))
);
await using CancellationTokenRegistration link =
cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10))
);
CommandResult process = await Cli.Wrap(scanner)
.WithArguments(arguments)

View File

@@ -64,13 +64,12 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
.OrderBy(lms => lms.Id)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(
lms => new LocalLibrary
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Path = p }).ToList(),
MediaKind = request.MediaKind,
MediaSourceId = lms.Id
})
.MapT(lms => new LocalLibrary
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Path = p }).ToList(),
MediaKind = request.MediaKind,
MediaSourceId = lms.Id
})
.Map(o => o.ToValidation<BaseError>("LocalMediaSource does not exist."));
}

View File

@@ -1,6 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Libraries;
public record CreateLocalLibraryPath(int LibraryId, string Path)
: IRequest<Either<BaseError, LocalLibraryPathViewModel>>;

View File

@@ -1,51 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries;
public class CreateLocalLibraryPathHandler : IRequestHandler<CreateLocalLibraryPath,
Either<BaseError, LocalLibraryPathViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public CreateLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
_libraryRepository = libraryRepository;
public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle(
CreateLocalLibraryPath request,
CancellationToken cancellationToken) =>
Validate(request).MapT(PersistLocalLibraryPath).Bind(v => v.ToEitherAsync());
private Task<LocalLibraryPathViewModel> PersistLocalLibraryPath(LibraryPath p) =>
_libraryRepository.Add(p).Map(ProjectToViewModel);
private Task<Validation<BaseError, LibraryPath>> Validate(CreateLocalLibraryPath request) =>
ValidateFolder(request)
.MapT(
folder =>
new LibraryPath
{
LibraryId = request.LibraryId,
Path = folder
});
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalLibraryPath request)
{
List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId)
.Map(list => list.Map(c => c.Path).ToList());
return Optional(request.Path)
.Where(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Path must not belong to another library path");
}
private static bool AreSubPaths(string path1, string path2)
{
string one = path1 + Path.DirectorySeparatorChar;
string two = path2 + Path.DirectorySeparatorChar;
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -19,23 +19,44 @@ public abstract class LocalLibraryHandlerBase
LocalLibrary localLibrary,
int? existingLibraryId = null)
{
List<string> allPaths = await dbContext.LocalLibraries
List<LocalPath> allPaths = await dbContext.LocalLibraries
.Include(ll => ll.Paths)
.Filter(ll => existingLibraryId == null || ll.Id != existingLibraryId)
.ToListAsync()
.Map(list => list.SelectMany(ll => ll.Paths).Map(lp => lp.Path).ToList());
.Map(list => list.SelectMany(ll => ll.Paths.Map(lp => new LocalPath(ll.MediaKind, lp.Path))).ToList());
return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path))))
var localPaths = localLibrary.Paths.Map(lp => new LocalPath(localLibrary.MediaKind, lp.Path)).ToList();
return Optional(localPaths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder))))
.Where(length => length == 0)
.Map(_ => localLibrary)
.ToValidation<BaseError>("Path must not belong to another library path");
}
private static bool AreSubPaths(string path1, string path2)
private static bool AreSubPaths(LocalPath path1, LocalPath path2)
{
string one = path1 + Path.DirectorySeparatorChar;
string two = path2 + Path.DirectorySeparatorChar;
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
string one = path1.Path + Path.DirectorySeparatorChar;
string two = path2.Path + Path.DirectorySeparatorChar;
bool isConflict = one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
// Images and OtherVideos do not conflict
if (isConflict)
{
bool imagesAndOtherVideos = path1.MediaKind is LibraryMediaKind.Images &&
path2.MediaKind is LibraryMediaKind.OtherVideos
|| path2.MediaKind is LibraryMediaKind.Images &&
path1.MediaKind is LibraryMediaKind.OtherVideos;
if (imagesAndOtherVideos)
{
isConflict = false;
}
}
return isConflict;
}
protected record LocalPath(LibraryMediaKind MediaKind, string Path);
}

View File

@@ -102,9 +102,8 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
UpdateLocalLibrary request) =>
LocalLibraryMustExist(dbContext, request)
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
.BindT(
parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
.MapT(_ => parameters));
.BindT(parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
.MapT(_ => parameters));
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
TvContext dbContext,
@@ -112,18 +111,18 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
dbContext.LocalLibraries
.Include(ll => ll.Paths)
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.Id)
.MapT(
existing =>
.MapT(existing =>
{
var incoming = new LocalLibrary
{
var incoming = new LocalLibrary
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Id = p.Id, Path = p.Path }).ToList(),
MediaSourceId = existing.Id
};
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Id = p.Id, Path = p.Path }).ToList(),
MediaKind = existing.MediaKind,
MediaSourceId = existing.Id
};
return new Parameters(existing, incoming);
})
return new Parameters(existing, incoming);
})
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist."));
private static string NormalizePath(string path) =>

View File

@@ -14,10 +14,9 @@ public class GetAllLocalLibrariesHandler : IRequestHandler<GetAllLocalLibraries,
GetAllLocalLibraries request,
CancellationToken cancellationToken) =>
_libraryRepository.GetAll()
.Map(
list => list
.OfType<LocalLibrary>()
.OrderBy(l => l.MediaKind)
.Map(ProjectToViewModel)
.ToList());
.Map(list => list
.OfType<LocalLibrary>()
.OrderBy(l => l.MediaKind)
.Map(ProjectToViewModel)
.ToList());
}

View File

@@ -15,12 +15,11 @@ public class GetConfiguredLibrariesHandler : IRequestHandler<GetConfiguredLibrar
GetConfiguredLibraries request,
CancellationToken cancellationToken) =>
_libraryRepository.GetAll()
.Map(
list => list.Filter(ShouldIncludeLibrary)
.OrderBy(l => l.MediaSource is LocalMediaSource ? 0 : 1)
.ThenBy(l => l.GetType().Name)
.ThenBy(l => l.MediaKind)
.Map(ProjectToViewModel).ToList());
.Map(list => list.Filter(ShouldIncludeLibrary)
.OrderBy(l => l.MediaSource is LocalMediaSource ? 0 : 1)
.ThenBy(l => l.GetType().Name)
.ThenBy(l => l.MediaKind)
.Map(ProjectToViewModel).ToList());
private static bool ShouldIncludeLibrary(Library library) =>
library switch

View File

@@ -46,8 +46,13 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti
.Map(jms => jms.Id)
.ToListAsync(cancellationToken);
return jellyfinMediaSourceIds.Map(
id => new LibraryViewModel("Jellyfin", 0, "Collections", 0, id, string.Empty));
return jellyfinMediaSourceIds.Map(id => new LibraryViewModel(
"Jellyfin",
0,
"Collections",
0,
id,
string.Empty));
}
private static async Task<IEnumerable<LibraryViewModel>> GetPlexExternalCollections(
@@ -59,7 +64,6 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti
.Map(pms => pms.Id)
.ToListAsync(cancellationToken);
return plexMediaSourceIds.Map(
id => new LibraryViewModel("Plex", 0, "Collections", 0, id, string.Empty));
return plexMediaSourceIds.Map(id => new LibraryViewModel("Plex", 0, "Collections", 0, id, string.Empty));
}
}

View File

@@ -27,9 +27,9 @@ public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, P
if (!string.IsNullOrWhiteSpace(request.Filter))
{
entries = entries.Filter(
le => le.Level.ToString().Contains(request.Filter, StringComparison.OrdinalIgnoreCase) ||
le.Message.Contains(request.Filter, StringComparison.OrdinalIgnoreCase));
entries = entries.Filter(le =>
le.Level.ToString().Contains(request.Filter, StringComparison.OrdinalIgnoreCase) ||
le.Message.Contains(request.Filter, StringComparison.OrdinalIgnoreCase));
}
int count = entries.Count();

View File

@@ -26,7 +26,7 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
EmptyTrash request,
CancellationToken cancellationToken)
{
SearchResult result = await _searchIndex.Search(_client, "state:FileNotFound", 0, 10_000);
SearchResult result = await _searchIndex.Search(_client, "state:FileNotFound", string.Empty, 0, 10_000);
var ids = result.Items.Map(i => i.Id).ToList();
// ElasticSearch remove items may fail, so do that first

View File

@@ -3,4 +3,4 @@
namespace ErsatzTV.Application.MediaCards;
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb, MediaItemState State) :
MediaCardViewModel(Id, Name, Role, Name, Thumb, State);
MediaCardViewModel(Id, Name, Role, Name, Thumb, State, HasMediaInfo: false);

View File

@@ -14,4 +14,5 @@ public record ArtistCardViewModel(
Subtitle,
SortTitle,
Poster,
State);
State,
HasMediaInfo: false);

View File

@@ -10,7 +10,8 @@ public record CollectionCardResultsViewModel(
List<MusicVideoCardViewModel> MusicVideoCards,
List<OtherVideoCardViewModel> OtherVideoCards,
List<SongCardViewModel> SongCards,
List<ImageCardViewModel> ImageCards)
List<ImageCardViewModel> ImageCards,
List<RemoteStreamCardViewModel> RemoteStreamCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}

View File

@@ -14,7 +14,8 @@ public record ImageCardViewModel(
Subtitle,
SortTitle,
Poster,
State)
State,
HasMediaInfo: true)
{
public int CustomIndex { get; set; }
}

View File

@@ -155,6 +155,15 @@ internal static class Mapper
string.Empty, // TODO: thumbnail?
imageMetadata.Image.State);
internal static RemoteStreamCardViewModel ProjectToViewModel(RemoteStreamMetadata remoteStreamMetadata) =>
new(
remoteStreamMetadata.RemoteStreamId,
remoteStreamMetadata.Title,
remoteStreamMetadata.OriginalTitle,
remoteStreamMetadata.SortTitle,
string.Empty, // TODO: thumbnail?
remoteStreamMetadata.RemoteStream.State);
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
@@ -171,8 +180,8 @@ internal static class Mapper
Option<EmbyMediaSource> maybeEmby) =>
new(
collection.Name,
collection.MediaItems.OfType<Movie>().Map(
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
collection.MediaItems.OfType<Movie>().Map(m =>
ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
{
CustomIndex = GetCustomIndex(collection, m.Id)
}).ToList(),
@@ -183,13 +192,12 @@ internal static class Mapper
.ToList(),
// collection view doesn't use local paths
collection.MediaItems.OfType<Episode>()
.Map(
e => ProjectToViewModel(
e.EpisodeMetadata.Head(),
maybeJellyfin,
maybeEmby,
false,
string.Empty))
.Map(e => ProjectToViewModel(
e.EpisodeMetadata.Head(),
maybeJellyfin,
maybeEmby,
true,
string.Empty))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
// collection view doesn't use local paths
@@ -200,7 +208,9 @@ internal static class Mapper
.ToList(),
collection.MediaItems.OfType<Song>().Map(s => ProjectToViewModel(s.SongMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<Image>().Map(i => ProjectToViewModel(i.ImageMetadata.Head())).ToList())
collection.MediaItems.OfType<Image>().Map(i => ProjectToViewModel(i.ImageMetadata.Head())).ToList(),
collection.MediaItems.OfType<RemoteStream>().Map(i => ProjectToViewModel(i.RemoteStreamMetadata.Head()))
.ToList())
{ UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel(

View File

@@ -8,4 +8,5 @@ public record MediaCardViewModel(
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State);
MediaItemState State,
bool HasMediaInfo);

View File

@@ -14,7 +14,8 @@ public record MovieCardViewModel(
Subtitle,
SortTitle,
Poster,
State)
State,
HasMediaInfo: true)
{
public int CustomIndex { get; set; }
}

View File

@@ -18,7 +18,8 @@ public record MusicVideoCardViewModel(
Subtitle,
SortTitle,
Poster,
State)
State,
HasMediaInfo: true)
{
public int CustomIndex { get; set; }
}

View File

@@ -14,7 +14,8 @@ public record OtherVideoCardViewModel(
Subtitle,
SortTitle,
Poster,
State)
State,
HasMediaInfo: true)
{
public int CustomIndex { get; set; }
}

View File

@@ -79,9 +79,11 @@ public class GetCollectionCardsHandler :
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
@@ -103,6 +105,12 @@ public class GetCollectionCardsHandler :
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Image).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.ThenInclude(ovm => ovm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core.Search;
namespace ErsatzTV.Application.MediaCards;
public record RemoteStreamCardResultsViewModel(int Count, List<RemoteStreamCardViewModel> Cards, SearchPageMap PageMap);

View File

@@ -0,0 +1,21 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards;
public record RemoteStreamCardViewModel(
int RemoteStreamId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
RemoteStreamId,
Title,
Subtitle,
SortTitle,
Poster,
State,
HasMediaInfo: true)
{
public int CustomIndex { get; set; }
}

View File

@@ -14,7 +14,8 @@ public record SongCardViewModel(
Subtitle,
SortTitle,
Poster,
State)
State,
HasMediaInfo: true)
{
public int CustomIndex { get; set; }
}

View File

@@ -24,4 +24,5 @@ public record TelevisionEpisodeCardViewModel(
$"Episode {Episode}",
SortTitle,
Poster,
State);
State,
HasMediaInfo: true);

View File

@@ -17,4 +17,5 @@ public record TelevisionSeasonCardViewModel(
Subtitle,
SortTitle,
Poster,
State);
State,
HasMediaInfo: false);

View File

@@ -14,4 +14,5 @@ public record TelevisionShowCardViewModel(
Subtitle,
SortTitle,
Poster,
State);
State,
HasMediaInfo: false);

View File

@@ -1,5 +1,6 @@
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -16,15 +17,18 @@ public class AddArtistToCollectionHandler :
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _searchChannel;
public AddArtistToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
ChannelWriter<ISearchIndexBackgroundServiceRequest> searchChannel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchChannel = searchChannel;
}
public async Task<Either<BaseError, Unit>> Handle(
@@ -41,6 +45,8 @@ public class AddArtistToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Artist);
if (await dbContext.SaveChangesAsync() > 0)
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Artist.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))

View File

@@ -1,5 +1,6 @@
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -16,15 +17,18 @@ public class AddEpisodeToCollectionHandler :
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _searchChannel;
public AddEpisodeToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
ChannelWriter<ISearchIndexBackgroundServiceRequest> searchChannel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchChannel = searchChannel;
}
public async Task<Either<BaseError, Unit>> Handle(
@@ -43,6 +47,8 @@ public class AddEpisodeToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Episode);
if (await dbContext.SaveChangesAsync() > 0)
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Episode.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))

View File

@@ -1,5 +1,6 @@
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -15,15 +16,18 @@ public class AddImageToCollectionHandler : IRequestHandler<AddImageToCollection,
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _searchChannel;
public AddImageToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
ChannelWriter<ISearchIndexBackgroundServiceRequest> searchChannel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchChannel = searchChannel;
}
public async Task<Either<BaseError, Unit>> Handle(
@@ -40,6 +44,8 @@ public class AddImageToCollectionHandler : IRequestHandler<AddImageToCollection,
parameters.Collection.MediaItems.Add(parameters.Image);
if (await dbContext.SaveChangesAsync() > 0)
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Image.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))

View File

@@ -12,4 +12,5 @@ public record AddItemsToCollection(
List<int> MusicVideoIds,
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds) : IRequest<Either<BaseError, Unit>>;
List<int> ImageIds,
List<int> RemoteStreamIds) : IRequest<Either<BaseError, Unit>>;

View File

@@ -1,5 +1,6 @@
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -17,6 +18,7 @@ public class AddItemsToCollectionHandler :
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMovieRepository _movieRepository;
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _searchChannel;
private readonly ITelevisionRepository _televisionRepository;
public AddItemsToCollectionHandler(
@@ -24,13 +26,15 @@ public class AddItemsToCollectionHandler :
IMediaCollectionRepository mediaCollectionRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
ChannelWriter<ISearchIndexBackgroundServiceRequest> searchChannel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_channel = channel;
_searchChannel = searchChannel;
}
public async Task<Either<BaseError, Unit>> Handle(
@@ -56,6 +60,7 @@ public class AddItemsToCollectionHandler :
.Append(request.OtherVideoIds)
.Append(request.SongIds)
.Append(request.ImageIds)
.Append(request.RemoteStreamIds)
.ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
@@ -67,6 +72,8 @@ public class AddItemsToCollectionHandler :
if (await dbContext.SaveChangesAsync() > 0)
{
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))

View File

@@ -12,4 +12,5 @@ public record AddItemsToPlaylist(
List<int> MusicVideoIds,
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds) : IRequest<Either<BaseError, Unit>>;
List<int> ImageIds,
List<int> RemoteStreamIds) : IRequest<Either<BaseError, Unit>>;

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