Compare commits

...
Author SHA1 Message Date
Jason Dove 3ea8193bb3 update changelog for release 55 [no ci] 2021-09-03 08:56:25 -05:00
Jason DoveandGitHub 8ad8680027 update dependencies; fix unnecessary table scrolling (#347) 2021-09-03 06:22:50 -05:00
Jason DoveandGitHub 640044814c ignore dot-underscore files (#346) 2021-09-03 06:22:33 -05:00
Jason Dove 18b5313a53 update docs [no docker] 2021-08-22 20:17:39 -05:00
Jason Dove 8417c3f6cd update changelog for release 54 [no ci] 2021-08-21 13:19:13 -05:00
Jason DoveandGitHub 32fdb414fa add "shuffle in order" playback order for multi-collections (#338)
* add "shuffle in order" option for multi-collections

* use balanced shuffle instead of random
2021-08-21 12:47:22 -05:00
Jason DoveandGitHub d3fc820aef update dependencies (#336)
* update dependencies

* fix fluent assertions
2021-08-21 06:23:43 -05:00
Jason DoveandGitHub 9d07627781 fix ffprobe parsing in some cultures (#337) 2021-08-21 05:57:39 -05:00
Jason DoveandGitHub d3c8914758 update dependencies (#331) 2021-08-14 07:20:09 -05:00
Jason Dove 3d7ec59088 update changelog for release 53 [no ci] 2021-08-01 12:37:10 -05:00
Jason Dove d78976f80a update changelog [no ci] 2021-08-01 12:22:32 -05:00
Jason DoveandGitHub 0f5fee99c6 always proxy jellyfin and emby artwork (#323) 2021-08-01 12:19:50 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d5bfd1a254 Bump MudBlazor from 5.0.15 to 5.1.0 (#321)
Bumps [MudBlazor](https://github.com/Garderoben/MudBlazor) from 5.0.15 to 5.1.0.
- [Release notes](https://github.com/Garderoben/MudBlazor/releases)
- [Changelog](https://github.com/Garderoben/MudBlazor/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/Garderoben/MudBlazor/compare/v5.0.15...v5.1.0)

---
updated-dependencies:
- dependency-name: MudBlazor
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-28 05:37:38 -05:00
Jason DoveandGitHub cd2558e3e6 fix jellyfin and emby links (#320) 2021-07-27 18:26:12 -05:00
Jason Dove c39654ca40 update changelog for release 52 [no ci] 2021-07-22 11:26:05 -05:00
Jason DoveandGitHub f17151bd20 fix multi-collection bug (#318) 2021-07-22 11:12:14 -05:00
Jason Dove 6aeaf65a13 update docs [no docker] 2021-07-20 20:13:21 -05:00
Jason DoveandGitHub 9fbe950e6e support multiple local libraries (#317)
* allow multiple local libraries

* add "move library path" function
2021-07-20 07:47:12 -05:00
Jason DoveandGitHub c9baff2cd5 add codeql workflow [no docker] 2021-07-18 07:36:41 -05:00
Jason Dove 447829385f update changelog for release 51 [no docker] 2021-07-18 07:11:11 -05:00
Jason DoveandGitHub a94d831866 add playout days to build setting (#316) 2021-07-18 06:10:54 -05:00
Jason DoveandGitHub 632753ea93 add multi collections (#315)
* start to add multi-collections

* create multi collection with no items

* edit multi collections

* fix plex credentials threading issue

* add playback order to multi collection items

* group episodes outside of shuffled enumerator

* move playback order onto each schedule item

* fix multi collection grouping

* update changelog
2021-07-17 21:45:41 -05:00
Jason Dove 4000c6bc0a update changelog [no docker] 2021-07-15 04:40:39 -05:00
Jason DoveandGitHub 1521469b2f add sqlite linux-arm artifacts (#314) 2021-07-15 04:39:04 -05:00
Jason Dove d6272c54a0 fix nuget references 2021-07-14 09:09:26 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d5039dc4fc Bump Serilog.Sinks.Console from 3.1.1 to 4.0.0 (#303)
Bumps [Serilog.Sinks.Console](https://github.com/serilog/serilog-sinks-console) from 3.1.1 to 4.0.0.
- [Release notes](https://github.com/serilog/serilog-sinks-console/releases)
- [Commits](https://github.com/serilog/serilog-sinks-console/compare/v3.1.1...v4.0.0)

---
updated-dependencies:
- dependency-name: Serilog.Sinks.Console
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-14 07:10:06 -05:00
Jason DoveandGitHub eba50523a9 fix release notes on homepage with alpha suffix (#312) 2021-07-14 05:35:11 -05:00
Jason Dove 1c1c1e7812 update changelog for release 50 [no docker] 2021-07-13 20:29:00 -05:00
Jason DoveandGitHub 5f802c7484 include linux-arm release artifacts [no docker] (#306) 2021-07-13 20:21:37 -05:00
Jason DoveandGitHub 7a06ac71e2 fix movie fallback metadata titles (#305) 2021-07-13 20:15:52 -05:00
Jason DoveandGitHub 3b9b8796b9 fix movie fallback metadata (#302) 2021-07-12 07:44:48 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a72d91507e Bump FluentValidation.AspNetCore from 10.2.3 to 10.3.0 (#301)
Bumps [FluentValidation.AspNetCore](https://github.com/JeremySkinner/fluentvalidation) from 10.2.3 to 10.3.0.
- [Release notes](https://github.com/JeremySkinner/fluentvalidation/releases)
- [Changelog](https://github.com/FluentValidation/FluentValidation/blob/main/Changelog.txt)
- [Commits](https://github.com/JeremySkinner/fluentvalidation/compare/10.2.3...10.3.0)

---
updated-dependencies:
- dependency-name: FluentValidation.AspNetCore
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-12 06:05:54 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
45bfbfc179 Bump FluentValidation from 10.2.3 to 10.3.0 (#300)
Bumps [FluentValidation](https://github.com/JeremySkinner/fluentvalidation) from 10.2.3 to 10.3.0.
- [Release notes](https://github.com/JeremySkinner/fluentvalidation/releases)
- [Changelog](https://github.com/FluentValidation/FluentValidation/blob/main/Changelog.txt)
- [Commits](https://github.com/JeremySkinner/fluentvalidation/compare/10.2.3...10.3.0)

---
updated-dependencies:
- dependency-name: FluentValidation
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-12 05:51:29 -05:00
Jason Dove 7d24701a82 update changelog for release 49 [no docker] 2021-07-11 18:00:50 -05:00
Jason DoveandGitHub 286580d5aa more sorting fixes (#299) 2021-07-11 14:40:31 -05:00
Jason DoveandGitHub d9457c01e5 add special case to multiple playout mode (#298) 2021-07-11 14:25:42 -05:00
Jason DoveandGitHub 22cf759a29 sorting fixes (#297)
* sorting fixes

* use natural sort for add to schedule dialog
2021-07-11 13:20:42 -05:00
Jason DoveandGitHub 0733a3d8d7 fix loading playout anchors (#296) 2021-07-11 10:47:25 -05:00
Jason DoveandGitHub 5f28707cce include audio language metadata in all streaming modes (#295)
* include audio language metadata in all streaming modes

* cleanup
2021-07-10 06:16:46 -05:00
Jason DoveandGitHub 45f1c6b22a properly flood with fixed start time (#294) 2021-07-09 14:16:24 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3bed81aee9 Bump MudBlazor from 5.0.14 to 5.0.15 (#292)
Bumps [MudBlazor](https://github.com/Garderoben/MudBlazor) from 5.0.14 to 5.0.15.
- [Release notes](https://github.com/Garderoben/MudBlazor/releases)
- [Changelog](https://github.com/Garderoben/MudBlazor/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/Garderoben/MudBlazor/compare/v5.0.14...v5.0.15)

---
updated-dependencies:
- dependency-name: MudBlazor
  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>
2021-07-09 13:49:52 -05:00
Jason DoveandGitHub f2eda3033c update dependencies (#289) 2021-06-24 06:35:50 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8ce989c3c9 Bump Microsoft.VisualStudio.Threading.Analyzers from 16.9.60 to 16.10.56 (#282)
Bumps [Microsoft.VisualStudio.Threading.Analyzers](https://github.com/microsoft/vs-threading) from 16.9.60 to 16.10.56.
- [Release notes](https://github.com/microsoft/vs-threading/releases)
- [Commits](https://github.com/microsoft/vs-threading/compare/v16.9.60...v16.10.56)

---
updated-dependencies:
- dependency-name: Microsoft.VisualStudio.Threading.Analyzers
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-23 13:06:41 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b5ba0dff27 Bump Blazored.LocalStorage from 4.1.1 to 4.1.2 (#286)
Bumps [Blazored.LocalStorage](https://github.com/Blazored/LocalStorage) from 4.1.1 to 4.1.2.
- [Release notes](https://github.com/Blazored/LocalStorage/releases)
- [Commits](https://github.com/Blazored/LocalStorage/compare/v4.1.1...v4.1.2)

---
updated-dependencies:
- dependency-name: Blazored.LocalStorage
  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>
2021-06-23 13:06:30 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
de3e2ea754 Bump NUnit3TestAdapter from 3.17.0 to 4.0.0 (#283)
Bumps [NUnit3TestAdapter](https://github.com/nunit/nunit3-vs-adapter) from 3.17.0 to 4.0.0.
- [Release notes](https://github.com/nunit/nunit3-vs-adapter/releases)
- [Commits](https://github.com/nunit/nunit3-vs-adapter/compare/V3.17...V4.0.0)

---
updated-dependencies:
- dependency-name: NUnit3TestAdapter
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-23 05:44:05 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2ac840b4bd Bump Markdig from 0.24.0 to 0.25.0 (#284)
Bumps [Markdig](https://github.com/xoofx/markdig) from 0.24.0 to 0.25.0.
- [Release notes](https://github.com/xoofx/markdig/releases)
- [Changelog](https://github.com/xoofx/markdig/blob/master/changelog.md)
- [Commits](https://github.com/xoofx/markdig/compare/0.24.0...0.25.0)

---
updated-dependencies:
- dependency-name: Markdig
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-23 05:43:53 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c8ccb5b0a0 Bump Microsoft.NET.Test.Sdk from 16.9.4 to 16.10.0 (#285)
Bumps [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest) from 16.9.4 to 16.10.0.
- [Release notes](https://github.com/microsoft/vstest/releases)
- [Commits](https://github.com/microsoft/vstest/compare/v16.9.4...v16.10.0)

---
updated-dependencies:
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-23 05:43:42 -05:00
Jason Dove 23c4fcf42c update changelog for release 48 [no docker] 2021-06-22 20:05:43 -05:00
Jason DoveandGitHub e2f3e86fd6 fix adding jellyfin emby seasons episodes (#281)
* fix adding new seasons and episodes with emby and jellyfin

* update changelog

* update dependencies
2021-06-22 18:55:44 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fd9f4a8f4e Bump MudBlazor from 5.0.10 to 5.0.14 (#278)
Bumps [MudBlazor](https://github.com/Garderoben/MudBlazor) from 5.0.10 to 5.0.14.
- [Release notes](https://github.com/Garderoben/MudBlazor/releases)
- [Changelog](https://github.com/Garderoben/MudBlazor/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/Garderoben/MudBlazor/compare/v5.0.10...v5.0.14)

---
updated-dependencies:
- dependency-name: MudBlazor
  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>
2021-06-22 06:28:01 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d5a0951a9b Bump FluentValidation from 10.1.0 to 10.2.3 (#275)
Bumps [FluentValidation](https://github.com/JeremySkinner/fluentvalidation) from 10.1.0 to 10.2.3.
- [Release notes](https://github.com/JeremySkinner/fluentvalidation/releases)
- [Changelog](https://github.com/FluentValidation/FluentValidation/blob/main/Changelog.txt)
- [Commits](https://github.com/JeremySkinner/fluentvalidation/compare/10.1.0...10.2.3)

---
updated-dependencies:
- dependency-name: FluentValidation
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-22 06:21:28 -05:00
Jason Dove 56d9724efd remove BOM from dependabot.yml [no docker] 2021-06-22 06:08:04 -05:00
Jason Dove f91b5ab3b5 assign dependabot prs [no docker] 2021-06-22 06:06:20 -05:00
Jason Dove 4b8e81ff06 add dependabot config [no docker] 2021-06-22 06:00:54 -05:00
Jason DoveandGitHub 1a7e6dda54 support 10-bit content with nvidia acceleration (#273)
* use ffprobe for plex statistics

* emby and jellyfin respect library refresh interval

* support 10-bit content with nvidia acceleration
2021-06-19 21:25:44 -05:00
Jason Dove 9fc6cdd0b7 update changelog for release 47 [no docker] 2021-06-15 16:25:01 -05:00
Jason DoveandGitHub cebab33d79 table improvements (#272)
* log viewer improvements

* playout detail table improvements

* schedule items table improvements

* remove schedule items pager
2021-06-15 16:10:24 -05:00
Jason DoveandGitHub b580125e86 fix searching when queries include non-ascii characters (#271) 2021-06-15 10:33:02 -05:00
Jason DoveandGitHub b38ba14c40 fix languages that have multiple codes (#270) 2021-06-15 06:12:40 -05:00
Jason DoveandGitHub c10bc6b184 fix blazor error font color (#269)
* fix blazor error font color

* changelog
2021-06-14 20:32:20 -05:00
Jason DoveandGitHub a75737a032 clear playout detail on delete (#266) 2021-06-14 18:44:10 -05:00
Jason DoveandGitHub 57aa14b764 fix adding channel with no watermark (#265) 2021-06-14 18:31:50 -05:00
Jason Dove 6e6d5a133f update changelog for release 46 [no docker] 2021-06-14 14:03:10 -05:00
Jason DoveandGitHub b4ba37f778 reset watermarks (#263) 2021-06-14 13:42:13 -05:00
Jason DoveandGitHub 275f82fcc9 save schedules and playouts table page sizes (#262) 2021-06-14 13:11:49 -05:00
Jason DoveandGitHub 72d967946d rework watermarks (#261)
* rework watermarks to be separate from channels

* update changelog
2021-06-14 10:29:58 -05:00
Jason DoveandGitHub a0740de972 add global and channel watermark overrides (#260)
* add global watermark setting

* add channel watermark override

* update changelog
2021-06-13 21:45:52 -05:00
Jason Dove e69569ea46 fix docker builds 2021-06-13 20:55:40 -05:00
Jason DoveandGitHub 679feb6d21 add watermark opacity (#259) 2021-06-13 20:45:23 -05:00
Jason DoveandGitHub 0fb5bfde58 refactor dbcontext lifetime (#258)
* refactor create playout handler

* refactor get all playouts handler

* refactor delete playout handler

* remove dead code

* ignore unnamed artists for collections

* more repository cleanup

* more schedule items refactoring

* more playout refactoring

* refactor playout builder

* refactor ffmpeg profiles

* more ffmpeg profile refactoring

* rework resolutions

* refactor media collections

* refactor config elements

* update changelog

* more cleanup
2021-06-13 20:19:10 -05:00
Jason Dove 4172074ac4 update changelog for release 45 [no docker] 2021-06-12 11:46:07 -05:00
Jason DoveandGitHub e9889cefd6 skip empty content rating (#257) 2021-06-12 11:31:32 -05:00
Jason DoveandGitHub fc59c9c284 include all content ratings in xmltv (#256) 2021-06-12 11:14:27 -05:00
Jason DoveandGitHub 0750a0712f allow animated channel watermarks (#255) 2021-06-12 06:16:52 -05:00
Jason DoveandGitHub 0365d4c8f8 add channel watermark (#254)
* wip

* wip

* implement watermark settings

* code cleanup

* update changelog
2021-06-11 21:42:06 -05:00
Jason DoveandGitHub 5b36252dd0 remove framerate normalization (#253) 2021-06-11 18:04:16 -05:00
Jason DoveandGitHub 7d852bc960 add hls hybrid mode (#252)
* fix serving channels.m3u with missing content ratings

* add hls hybrid mode
2021-06-10 20:42:58 -05:00
Jason Dove cdf10b0535 changelog for 44 again [no docker] 2021-06-09 18:41:11 -05:00
Jason Dove f0b429efb5 update changelog for release 44 [no docker] 2021-06-09 18:39:52 -05:00
Jason DoveandGitHub da5148affd quickly skip missing files during plex library scan (#251) 2021-06-07 20:34:24 -05:00
Jason DoveandGitHub cec5a09839 add us content ratings to xmltv (#250) 2021-06-07 18:58:38 -05:00
Jason DoveandGitHub e20f9be702 exclude strm files from jellyfin scanners (#249)
* exclude strm files from jellyfin scanners

* update changelog
2021-06-07 07:41:59 -05:00
Jason Dove 3bc3faa7c4 artist schedule doc update [no docker] 2021-06-06 20:25:39 -05:00
Jason DoveandGitHub db24ba84f7 add artists directly to schedules (#248) 2021-06-06 20:12:17 -05:00
Jason DoveandGitHub 8346a02747 ignore unsupported plex guids (#246) 2021-06-05 15:53:38 -05:00
Jason Dove c3b33c184f fix changelog [no docker] 2021-06-05 13:39:29 -05:00
Jason Dove 6bec9c5f07 update docs for 0.0.43-prealpha [no docker] 2021-06-05 13:36:08 -05:00
Jason DoveandGitHub 0ef03d66f3 improve hls direct compatibility with channels dvr (#245)
* rename HttpLiveStreaming to HttpLiveStreamingDirect

* improve hls direct compatibility with channels dvr

* code cleanup
2021-06-05 13:15:39 -05:00
Jason DoveandGitHub 10c422a3eb save channels table page size (#244)
* save channel table page size

* update changelog
2021-06-05 10:15:34 -05:00
Jason DoveandGitHub 6c867d0d51 support multi-episode files from plex (#243)
* minor fallback metadata bug fixes

* support multi-episode files from plex
2021-06-04 15:06:19 -05:00
Jason DoveandGitHub ed0796ad58 force scan local season folders to pick up multi-episode files (#242) 2021-06-04 11:42:03 -05:00
Jason DoveandGitHub 49109ac121 fix missing season metadata (#241) 2021-06-04 10:37:56 -05:00
Jason DoveandGitHub 3e3bbcf38e support multi-episode files in local libraries (#240)
* add unused episode nfo reader

* move episode number from episode to episode metadata

* first pass at loading multi-episode metadata from nfo files

* fix episode scanning

* local multi-part episode fixes

* code cleanup
2021-06-04 06:00:35 -05:00
Jason DoveandGitHub ce9ef72799 support (part #) names for multi-episode grouping (#238) 2021-06-01 07:15:17 -05:00
Jason Dove f8631a1f12 release 0.0.42-prealpha 2021-05-31 13:08:55 -05:00
Jason DoveandGitHub c70f153241 keep crossover episodes together (#237) 2021-05-31 11:58:56 -05:00
Jason DoveandGitHub eee10dee22 skip zero duration items when building playouts (#236) 2021-05-31 07:31:15 -05:00
Jason DoveandGitHub 9f575dbd94 fix stuck playout builds (#235)
* fix stuck playout builds

* code cleanup
2021-05-31 06:38:54 -05:00
Jason Dove 539285d81e update docs for 0.0.41-prealpha release [no docker] 2021-05-30 18:01:13 -05:00
Jason DoveandGitHub f8c986472a more grouping fixes (#234)
* more grouping fixes

* update changelog
2021-05-30 11:54:02 -05:00
Jason Dove 442d73150e link to full changelog on home page [no docker] 2021-05-30 10:47:33 -05:00
Jason DoveandGitHub d6cee14143 add changelog (#232) 2021-05-30 09:40:59 -05:00
Jason DoveandGitHub c20c0b231e fix updating local movies (#231) 2021-05-30 06:32:26 -05:00
Jason DoveandGitHub e506dd38a8 merge latest develop (#230)
* sync guids/provider ids (#227)

* sync guids from plex

* cleanup

* sync local guids

* sync jellyfin and emby guids

* add episodes to search index (#228)

* sync episode directors and writers

* display episode writers and directors

* remove missing episodes from search index

* show episodes in search results

* fix emby and jellyfin episode updates

* fix updating plex episodes

* don't delete channel logos on startup

* add episodes page; fix adding episodes to collection

* cleanup

* multi-part episode grouping fixes (#229)
2021-05-30 06:08:33 -05:00
Jason DoveandGitHub bbd8bc6c7e add button to refresh list of libraries (#226)
* add button to refresh list of libraries

* code cleanup
2021-05-28 15:28:45 -05:00
Jason DoveandGitHub e841c9c53b fix missing artwork (#225) 2021-05-28 09:34:47 -05:00
Jason DoveandGitHub 4c78f41c5a fix incorrect search items count (#224) 2021-05-28 06:13:40 -05:00
Jason DoveandGitHub 95cceb95b9 regularly delete orphaned artwork from db (#223) 2021-05-28 05:38:58 -05:00
Jason DoveandGitHub 58d6f81d2e recursively retrieve jellyfin and emby items (#221) 2021-05-27 21:01:25 -05:00
Jason DoveandGitHub fe5cedfcdc disable ffmpeg reports on windows (#220)
* disable ffmpeg reports for windows

* code cleanup
2021-05-27 16:36:02 -05:00
Jason DoveandGitHub 0bbed69e85 add movie directors and writers (#219) 2021-05-27 09:24:47 -05:00
Jason DoveandGitHub 68123a2f9c add content rating (#218)
* add new columns

* store local content ratings

* display and search content ratings

* add content_rating to search docs

* sync content rating from jellyfin, emby, plex

* force sync content rating for all libraries

* code cleanup
2021-05-27 06:41:24 -05:00
Jason DoveandGitHub 6504ca10a8 cache local artwork on disk (#217) 2021-05-26 19:49:08 -05:00
Jason DoveandGitHub 84770ed250 use artwork for schedule items with custom title when all media items are from same show (#216) 2021-05-26 15:34:54 -05:00
Jason DoveandGitHub 466d33f808 sync tv show thumb art (#214)
* sync thumb art from local, jellyfin, emby

* code cleanup
2021-05-26 12:46:23 -05:00
Jason DoveandGitHub 8e81d5f197 fix add to schedule dialog (#213) 2021-05-26 08:43:11 -05:00
Jason DoveandGitHub da43e6f7cf embed debug symbols (#212) 2021-05-26 07:26:14 -05:00
Jason DoveandGitHub c9905d0542 fix resources (offline background and font) (#211) 2021-05-25 15:42:03 -05:00
Jason DoveandGitHub c9e20e28df proxy jellyfin and emby artwork for xmltv (#210)
* fix xmltv artwork for jf and emby

* proxy jellyfin and emby artwork for xmltv
2021-05-25 15:15:15 -05:00
Jason DoveandGitHub f9427cac99 use multiple docker tags again (#209)
* Revert "disable framerate normalization (#208)"

This reverts commit 141a34933d.

* Revert "use linuxserver base docker image (#207)"

This reverts commit 0962a1429a.

* fix playback that only uses fps filter

* nvidia needs privileged
2021-05-25 05:13:51 -05:00
Jason DoveandGitHub 141a34933d disable framerate normalization (#208)
* disable framerate normalization

* fix test
2021-05-24 21:47:26 -05:00
Jason DoveandGitHub 0962a1429a use linuxserver base docker image (#207)
* use one base docker image

* remove nvidia and vaapi tags

* fix playback that only uses fps filter
2021-05-24 21:12:55 -05:00
Jason DoveandGitHub f8b45ed9db fix unc path replacements from jellyfin and emby (#205)
* fix UNC path replacements from non-windows JF and Emby servers

* use emby path replacements for playback
2021-05-24 09:03:39 -05:00
611 changed files with 91757 additions and 19276 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"jetbrains.resharper.globaltools": { "jetbrains.resharper.globaltools": {
"version": "2020.3.2", "version": "2021.1.3",
"commands": [ "commands": [
"jb" "jb"
] ]
+8
View File
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: daily
assignees:
- jasongdove
+71
View File
@@ -0,0 +1,71 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '30 3 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'csharp' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
+6 -5
View File
@@ -11,6 +11,9 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
kind: linux kind: linux
target: linux-x64 target: linux-x64
- os: ubuntu-latest
kind: linux
target: linux-arm
- os: windows-latest - os: windows-latest
kind: windows kind: windows
target: win-x64 target: win-x64
@@ -39,24 +42,22 @@ jobs:
# Define some variables for things we need # Define some variables for things we need
tag=$(git describe --tags --abbrev=0) tag=$(git describe --tags --abbrev=0)
release_name="ErsatzTV-$tag-${{ matrix.target }}" release_name="ErsatzTV-$tag-${{ matrix.target }}"
#release_name_cli="ErsatzTV.CommandLine-$tag-${{ matrix.target }}"
# Build everything # Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:PublishSingleFile=true --self-contained true dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:PublishSingleFile=true --self-contained true
#dotnet publish ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli" /property:InformationalVersion="${tag:1}-${{ matrix.target }}"
# Pack files # Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then if [ "${{ matrix.target }}" == "win-x64" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*" 7z a -tzip "${release_name}.zip" "./${release_name}/*"
#7z a -tzip "${release_name_cli}.zip" "./${release_name_cli}/*" elif [ "${{ matrix.target }}" == "linux-arm" ]; then
cp lib/linux-arm/* "$release_name/"
tar czvf "${release_name}.tar.gz" "$release_name"
else else
tar czvf "${release_name}.tar.gz" "$release_name" tar czvf "${release_name}.tar.gz" "$release_name"
#tar czvf "${release_name_cli}.tar.gz" "$release_name_cli"
fi fi
# Delete output directory # Delete output directory
rm -r "$release_name" rm -r "$release_name"
#rm -r "$release_name_cli"
- name: Publish - name: Publish
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
+606
View File
@@ -0,0 +1,606 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.0.55-alpha] - 2021-09-03
### Fixed
- Fix all local library scanners to ignore dot underscore files (`._`)
## [0.0.54-alpha] - 2021-08-21
### Added
- Add `Shuffle In Order` playback order for multi-collections.
- This is useful for randomizing multiple collections/shows on a single channel, while each collection maintains proper ordering (custom or chronological)
### Fixed
- Fix bug parsing ffprobe output in cultures where `.` is a group/thousands separator
- This bug likely prevented ETV from scheduling correctly or working at all in those cultures
- After installing a version with this fix, affected content will need to be removed from ETV and re-added
## [0.0.53-alpha] - 2021-08-01
### Fixed
- Fix error message displayed after building empty playout
- Fix Emby and Jellyfin links
### Changed
- Always proxy Jellyfin and Emby artwork; this fixes artwork in some networking scenarios
## [0.0.52-alpha] - 2021-07-22
### Added
- Add multiple local libraries to better organize your media
- Add `Move Library Path` function to support reorganizing existing local libraries
### Fixed
- Fix bug preventing playouts from rebuilding after an empty collection is encountered within a multi-collection
## [0.0.51-alpha] - 2021-07-18
### Added
- Add `Multi Collection` to support shuffling multiple collections within a single schedule item
- Collections within a multi collection are optionally grouped together and ordered when scheduling; this can be useful for franchises
- Add `Playout Days To Build` setting to control how many days of playout data/program guide data should be built into the future
### Changed
- Move `Playback Order` from schedule to schedule items
- This allows different schedule items to have different playback orders within a single schedule
### Fixed
- Fix release notes on home page with `-alpha` suffix
- Fix linux-arm release by including SQLite interop artifacts
- Fix issue where cached Plex credentials may become invalid when multiple servers are used
## [0.0.50-alpha] - 2021-07-13
### Added
- Add Linux ARM release artifacts which can be used on Raspberry Pi devices
### Fixed
- Fix bug preventing ingestion of local movies with fallback metadata (without NFO files)
- Fix extra spaces in titles of local movies with fallback metadata (without NFO files)
## [0.0.49-prealpha] - 2021-07-11
### Added
- Include audio language metadata in all streaming modes
- Add special zero-count case to `Multiple` playout mode
- This configuration will automatically maintain the multiple count so that it is equal to the number of items in the collection
- This configuration should be used if you want to play every item in a collection exactly once before advancing
### Changed
- Use case-insensitive sorting for collections page and `Add to Collection` dialog
- Use case-insensitive sorting for all collection lists in schedule items editor
- Use natural sorting for schedules page and `Add to Schedule` dialog
### Fixed
- Fix flooding schedule items that have a fixed start time
## [0.0.48-prealpha] - 2021-06-22
### Added
- Store pixel format with media statistics; this is needed to support normalization of 10-bit media items
- This requires re-ingesting statistics for all media items the first time this version is launched
### Changed
- Use ffprobe to retrieve statistics for Plex media items (Local, Emby and Jellyfin libraries already use ffprobe)
### Fixed
- Fix playback of transcoded 10-bit media items (pixel format `yuv420p10le`) on Nvidia hardware
- Emby and Jellyfin scanners now respect library refresh interval setting
- Fix adding new seasons to existing Emby and Jellyfin shows
- Fix adding new episodes to existing Emby and Jellyfin seasons
## [0.0.47-prealpha] - 2021-06-15
### Added
- Add warning during playout rebuild when schedule has been emptied
- Save Logs, Playout Detail, Schedule Detail table page sizes
### Changed
- Show all log entries in log viewer, not just most recent 100 entries
- Use server-side paging and sorting for Logs table
- Use server-side paging for Playout Detail table
- Remove pager from Schedule Items editor (all schedule items will always be displayed)
### Fixed
- Fix ui crash adding a channel without a watermark
- Clear playout detail table when playout is deleted
- Fix blazor error font color
- Fix some audio stream languages missing from UI and search index
- Fix audio stream selection for languages with multiple codes
- Fix searching when queries contain non-ascii characters
## [0.0.46-prealpha] - 2021-06-14
### Added
- Add watermark opacity setting to allow blending with content
- Add global watermark setting; channel-specific watermarks have precedence over global watermarks
- Save Schedules, Playouts table page sizes
### Changed
- Remove unused API and SDK project; may reintroduce in the future but for now they have fallen out of date
- Rework watermarks to be separate from channels (similar to ffmpeg profiles)
- **All existing watermarks have been removed and will need to be recreated using the new page**
- This allows easy watermark reuse across channels
### Fixed
- Fix ui crash adding or editing schedule items due to Artist with no name
- Fix many potential sources of inconsistent data in UI
## [0.0.45-prealpha] - 2021-06-12
### Added
- Add experimental `HLS Hybrid` channel mode
- Media items are transcoded using the channel's ffmpeg profile and served using HLS
- Add optional channel watermark
### Changed
- Remove framerate normalization; it caused more problems than it solved
- Include non-US (and unknown) content ratings in XMLTV
### Fixed
- Fix serving channels.m3u with missing content ratings
- Fix percent progress indicator for Jellyfin and Emby show library scans
## [0.0.44-prealpha] - 2021-06-09
### Added
- Add artists directly to schedules
- Include MPAA and VCHIP content ratings in XMLTV guide data
- Quickly skip missing files during Plex library scan
### Fixed
- Ignore unsupported plex guids (this prevented some libraries from scanning correctly)
- Ignore unsupported STRM files from Jellyfin
## [0.0.43-prealpha] - 2021-06-05
### Added
- Support `(Part #)` name suffixes for multi-part episode grouping
- Support multi-episode files in local and Plex libraries
- Save Channels table page size
- Add optional query string parameter to M3U channel playlist to allow some customization per client
- `?mode=ts` will force `MPEG-TS` mode for all channels
- `?mode=hls-direct` will force `HLS Direct` mode for all channels
- `?mode=mixed` or no parameter will maintain existing behavior
### Changed
- Rename channel mode `TransportStream` to `MPEG-TS` and `HttpLiveStreaming` to `HLS Direct`
- Improve `HLS Direct` mode compatibility with Channels DVR Server
### Fixed
- Fix search result crashes due to missing season metadata
## [0.0.42-prealpha] - 2021-05-31
### Added
- Support roman numerals and english integer names for multi-part episode grouping
- Add option to treat entire collection as a single show with multi-part episode grouping
- This is useful for multi-part episodes that span multiple shows (crossovers)
### Changed
- Skip zero duration items when building a playout, rather than aborting the playout build
### Fixed
- Fix edge case where a playout rebuild would get stuck and block all other playouts and local library scans
## [0.0.41-prealpha] - 2021-05-30
### Added
- Add button to refresh list of Plex, Jellyfin, Emby libraries without restarting app
- Add episodes to search index
- Add director and writer metadata to episodes
- Add unique id/provider id metadata, which will support future features
- Allow grouping multi-part episodes with titles ending in `Part X`, `Part Y`, etc.
### Changed
- Change home page link from release notes to full changelog
### Fixed
- Fix missing channel logos after restart
- Fix multi-part episode grouping with missing episodes/parts
- Fix multi-part episode grouping in collections containing multiple shows
- Fix updating modified seasons and episodes from Jellyfin and Emby
## [0.0.40-prealpha] - 2021-05-28
### Added
- Add content rating metadata to movies and shows
- Add director and writer metadata to movies
- Sync tv show thumbnail artwork in Local, Jellyfin and Emby libraries (*not* Plex)
- Prioritize tv show thumbnail artwork over tv show posters in XMLTV
- Include tv show artwork in XMLTV when grouped items with custom title are all from the same show
- Cache resized local artwork on disk
### Fixed
- Recursively retrieve Jellyfin and Emby items
- Fix incorrect search item counts
- Fix stack trace information in non-docker releases
- Fix crash opening `Add to Schedule` dialog
- Disable FFmpeg troubleshooting reports on Windows as they do not work properly
## [0.0.39-prealpha] - 2021-05-25
### Added
- Show Jellyfin and Emby artwork in XMLTV
### Fixed
- Fix path replacements for Jellyfin and Emby, including UNC paths
- Use Emby path replacements for playback
- Fix playback when `fps` is the only required filter
- Fix resources (images, fonts) required to display offline channel message
## [0.0.38-prealpha] - 2021-05-23
### Added
- Add support for Emby
- Use "single-file" deployments for releases
- Non-docker releases will have significantly fewer files
- It is recommended to empty your installation folder before copying in the latest release.
### Fixed
- Fix some cases where Jellyfin artwork would not display
- Fix saving schedule items with duration less than one hour
- Use ffmpeg 4.3 in docker images; there was a performance regression with 4.4 (only in docker)
## [0.0.37-prealpha] - 2021-05-21
### Added
- Add option to keep multi-part episodes together when shuffling (i.e. two-part season finales)
- Optimize Plex TV Scanner to quickly process shows that have not been updated since the last scan
- Optimize local Movie, Show, Music Video scanners to quickly skip unchanged folders, and to notice any mtime change
- Add server binding configuration to `appsettings.json` which lets non-docker installations bind to localhost or change the port number
### Fixed
- Properly ignore `Other` Jellyfin libraries
- Fix bug where search index would try to re-initialize unnecessarily
- Fix one cause of green line at bottom of some transcoded videos by forcing even scaling targets
## [0.0.36-prealpha] - 2021-05-16
### Added
- Add support for Jellyfin
- Add support for ffmpeg 4.4, and use ffmpeg 4.4 in all docker images
- Add configurable library refresh interval
- Add button to copy/clone ffmpeg profile
## [0.0.35-prealpha] - 2021-04-27
### Added
- Add search button for each library in `Libraries` page to quickly filter content by library
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Fixed
- Fix ingesting actors and actor artwork from newly-added Plex media items
- Only show `movie` and `show` libraries from Plex. Other library types are not supported at this time.
- Fix local movie scanner missing replaced/updated files
## [0.0.34-prealpha] - 2021-04-17
### Added
- Allow `enter` key to submit all dialogs
- Add actors to movies and shows (Plex or NFO metadata is required)
- Note that this requires a one-time full library scan to ingest actor metadata, which may take a long time with large libraries.
- Rework metadata list links in UI (languages, studios, genres, etc)
### Fixed
- Fix EPG generation with music video channels that do not use a custom title
- Fix lag when typing in search bar, `Add To Collection` dialog
- Fix collections paging
- Fix padding odd resolutions (this bug caused some items to always fail playback)
- Only update Plex episode artwork as needed
## [0.0.33-prealpha] - 2021-04-11
### Added
- Add language buttons to movies, shows, artists
- Show release notes on home page
### Fixed
- Re-import missing television metadata that was incorrectly removed with `v0.0.32`
- Fix language indexing; language searches now use full english name
- Fix synchronizing television studios, genres from Plex
- Limit channels to one playout per channel
- Though more than one playout was previously possible it was unsupported and unlikely to work as expected, if at all
- A future release may make this possible again
## [0.0.32-prealpha] - 2021-04-09
### Added
- `Add All To Collection` button to quickly add all search results to a collection
- Add Artists scanned from Music Video libraries
- Artist folders are now required, but music videos now have no naming requirements
- `artist.nfo` metadata is supported along with thumbnail and poster artwork
- Save Collections table page size in local storage
### Fixed
- Fix audio stream language indexing for movies and music videos
- Fix synchronizing list of Plex servers and connection addresses for each server
- Fix `See All` link for music video search results
## [0.0.31-prealpha] - 2021-04-06
### Added
- Add documentation link to UI
- Add `language` search field
- Minor log viewer improvements
- Use fragment navigation with letter bar (clicking a letter will page and scroll until that letter is in view)
- Send all audio streams with HLS when channel has no preferred language
- Move FFmpeg settings to new `Settings` page
- Add HDHR tuner count setting to new `Settings` page
### Fixed
- Fix poster width
- Fix bug that would occasionally prevent items from being added to the search index
- Automatically refresh the Plex Media Sources page after sign in or sign out
## 0.0.30-prealpha [YANKED]
## [0.0.29-prealpha] - 2021-04-04
- No longer require NFO metadata for music videos
- Instead, the only requirement is that music video files be named `[artist] - [track].[extension]` where the three characters (space dash space) between artist and track are required
- Add library scan progress detail
- Optimize library scans after adding library path to only scan new library path
- Fix bug replacing music videos
- Scan Plex libraries and local libraries on different threads
- Use English names for preferred languages in UI instead of ISO language code
## [0.0.28-prealpha] - 2021-04-03
- Apply audio normalization more consistently; this should further reduce program boundary errors
- Replace unused audio volume setting with audio loudness normalization option
- This can be particularly helpful with music video channels if media items have inconsistent loudness
- This setting may be less desirable on movie channels where loudness is intended to be dynamic
- Fix XMLTV containing music videos that do not use a custom title
- Fix channels table sorting, add paging to channels table
- Add sorting and paging to schedules table
- Add paging to playouts table
- Use table instead of cards for collections view
## [0.0.27-prealpha] - 2021-04-02
- Add ***basic*** music video library support
- **NFO metadata is required for music videos** - see [tags](https://kodi.wiki/view/NFO_files/Music_videos#Music_Video_Tags), [template](https://kodi.wiki/view/NFO_files/Music_videos#Template_nfo) and [sample](https://kodi.wiki/view/NFO_files/Music_videos#Sample_nfo)
- Artists can be searched using the `artist` field, like `artist:daft`
- Clear search query when clicking `Movies` or `TV Shows` from paged search results
- Add show title to playout details
- Let ffmpeg determine thread count by default (signified by `0` threads in ffmpeg profile)
- Save troubleshooting reports for ffmpeg concat process in addition to transcode process
- Simplify ffmpeg normalization options
- Add frame rate setting to ffmpeg profile
- When video normalization is enabled, all media items will have their frame rate converted to the same value
- Fix some scenarios where streaming would freeze at program boundaries
- Fix bug preventing some Plex libraries from scanning
- Fix bug preventing some local libraries from scanning folders that were recently added
## [0.0.26-prealpha] - 2021-03-30
- Add `Custom Title` option to schedule items
- When a custom title is set, the schedule item will be grouped in the EPG with the custom title
- Navigate to schedule items after creating new schedule
- Fix channel editor so preferred language is no longer required on every channel
- Fix bug with audio track selection during non-normalized playback
- Fix bug with playout builds where `Multiple` or `Duration` items wouldn't respect the settings over time
- Fix bug that prevented some television folders from scanning
## [0.0.25-prealpha] - 2021-03-29
- Add preferred language feature
- Global preference can be set in FFmpeg settings; channels can override global preference
- Preferences require [ISO 639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language codes
- Audio stream selection will attempt to respect these preferences and prioritize streams with the most channels
- English (`eng`) will be used as a fallback when no preferences have been configured
- ***This feature requires a one-time reanalysis of every media item which may take a long time for large libraries and playback may fail until this scan has completed***
- Fix channel sorting in EPG
- Fix mixed-platform path replacements (Plex on Windows with ErsatzTV on Linux, or Plex on Linux with ErsatzTV on Windows)
- Fix local television library scanning; this was broken with `v0.0.23`
- Optimize local library scanning; regular scans should be significantly faster
- Add log warning when a zero-duration media item is encountered
- Fix indexing local shows without NFO metadata.
- If you have this issue the best way to fix is to:
- Shutdown ErsatzTV
- Delete the `search-index` subfolder inside the ErsatzTV config folder
- Start ErsatzTV; the full search index will be rebuilt on startup
- Fix updating search index when genres, tags, studios are updated in local libraries
- Adjust artwork routes so all IPTV traffic can be proxied with a single path prefix of `/iptv`
## [0.0.24-prealpha] - 2021-03-22
- Fix a critical bug preventing library synchronization with Plex sign ins performed with `v0.0.22` or `v0.0.23`
- **If you are unable to sync libraries from Plex, please sign out and back in to apply this fix**
- Fallback to `folder.jpg` when `poster.jpg` is not present
- Attach episodes to correct show when adding NFO metadata to existing libraries
## [0.0.23-prealpha] - 2021-03-21
- Remove all Plex items from search index after sign out
- Fix fallback metadata for local episodes (episode number was missing)
- Improve television show year detection where year is missing from nfo metadata
- Fix sorting for titles that start with `A` or `An` in addition to `The`
- Properly escape search links containing special characters (genre, tag)
- Add and index `Studio` metadata
## [0.0.22-prealpha] - 2021-03-20
- Log errors encountered during search index build; attempt to continue with partial index when errors are encountered
- Only search `title` field by default; `genre` and `tag` can be searched with `field:query` syntax
- Allow leading wildcards in searches
- Keep search query in search field to allow easy modification
- Fix default ffmpeg profile when creating new channels
- Fix multiple bugs with updating Plex servers, libraries, path replacements
- Add `release_date` to search index
## [0.0.21-prealpha] - 2021-03-20
- Optimize local library scanning to use less memory
- Duplicate some documentation near the schedule item editor
- Fix bug with updating `Normalize Video Codec` setting
- Rework search functionality
- Search landing page will show up to 50 items of each type
- `See All` links can be used to page through all search results
- Complex search queries supported (`christmas OR santa`)
- Fields that are searched by default:
- `title`
- `genre`
- `tag`
- Fields that aren't searched by default, but can be included in queries with syntax like (`plot:whatever`):
- `plot`
- `library_name`
- `type` (`movie` or `show`)
- Add letter bar to all paged search results to quickly navigate to a particular letter
## [0.0.20-prealpha] - 2021-03-17
- Fix NVIDIA hardware acceleration in `develop-nvidia` and `latest-nvidia` Docker tags
- This may never have worked correctly in Docker with older releases
- Fix occasional crash rebuilding playout from ui
- Fix crash adding a channel when no channels exist
- Fix playback for media containing attached pictures
## [0.0.19-prealpha] - 2021-03-16
- Regularly scan Plex libraries (same as local libraries)
- Add ability to create new collection from `Add to Collection` dialog
- Fix channel logos in XMLTV
- Add episode posters (show posters) to XMLTV
- Fix shuffled schedules from occasionally having repeated items when reshuffling
- This was more likely to happen with low-cardinality collections like A B C C A B B C A
- Add optional FFmpeg troubleshooting reports
- Allow synchronizing hidden Plex libraries
## [0.0.18-prealpha] - 2021-03-14
- Plex is now a supported media source
- Plex is **not** used for transcoding at this point, files are played directly from the filesystem using ErsatzTV transcoding
- Path replacements will be needed if your shared media folders are mounted differently in Plex and ErsatzTV
## [0.0.17-prealpha] - 2021-03-13
- Fix bug introduced with 0.0.16 that prevented some playouts from building
- Properly set sort title on added tv shows
- Fix loading season pages containing episodes that have incomplete metadata
- Improve XMLTV guide data
## [0.0.16-prealpha] - 2021-03-12
- Fix infinite loop caused by incorrectly configured ffprobe path
- Add more strict ffmpeg and ffprobe settings validation
- Add custom playback order option to collections that contain only movies
- This custom playback order will override the schedule's configured playback order for the collection
## [0.0.15-prealpha] - 2021-03-11
- Update UI for tv shows
- Fix tv show sorting
- Fix editing channel numbers
- Fix playout timezone bugs
- Add searchable genres and tags from local NFO metadata
- Add multi-select feature to movies, shows, search results and collection items pages
## [0.0.14-prealpha] - 2021-03-09
- New movie layout utilizing fan art (if available)
- New dark UI
- Fix offline stream (displayed when no media is scheduled for playback)
- Add M3U codec hints for Channels DVR
- Allow sub-channel numbers
- Fix bug where ffmpeg wouldn't terminate after a media item completed playback
- Fix time zone in new docker base images
- Fix vaapi pipeline with mpeg4 content by using software decoder with hardware encoder
- Enforce unique schedule name
- Enforce unique channel number
- Fix sorting of collection items in UI
## [0.0.13-prealpha] - 2021-03-07
- Remember selected Collection in `Add To Collection` dialog
- Automatically rebuild Playouts referencing any Collection that has items added or removed from the UI
- Remove Media Items from database when files are removed from disk
- Add hardware-accelerated transcoding support (`qsv`, `nvenc`/`nvidia`, `vaapi`)
- All flavors support resolution normalization (scaling and padding)
- This requires support within ffmpeg; see README for new docker image tags
## [0.0.12-prealpha] - 2021-03-02
- Fix a database migration issue introduced with version 0.0.11
- Shutdown app when database migration failures are encountered at startup
## [0.0.11-prealpha] - 2021-03-01
- Add Libraries and Library Paths under Media Sources
- Two local libraries exist: `Movies` and `Shows`
- Local Media Sources from prior versions are now found under Library Paths
- Add `Rebuild Playout` buttons to quickly regenerate playouts after modifying collections
- Add `Add to Collection` buttons to most media cards (movies, shows, seasons, episodes)
- Add Search page for searching movies and shows
## [0.0.10-prealpha] - 2021-02-21
- Rework how television media is stored in the database
- Rework how media is linked to a collection
- Add season, episode and movie detail views to UI
- Add media to collections and schedules from detail views
- Easily add and remove media from a collection
- Easily add and reorder schedule items
## [0.0.9-prealpha] - 2021-02-15
- Local media scanner has been rewritten and is much more performant
- Ignore extras in the same folder as movies (`-behindthescenes`, `-trailer`, etc)
- Support `movie.nfo` metadata in addition to matching filename nfo metadata
- Changes to video files, metadata and posters are automatically detected and used
## [0.0.8-prealpha] - 2021-02-14
- Optimize scanning so playouts are only rebuilt when necessary (duration changes, or collection membership changes)
- Automatically add new posters during scanning
- Support more poster file types (jpg, jpeg, png, gif, tbn)
- Add "Refresh All Metadata" button to media sources page; this should only be needed if NFO metadata or posters are modified
- Add progress indicator for media sources that are being actively scanned
- Prevent deleting media source during scan
- Prevent creating playout with empty schedule
## [0.0.7-prealpha] - 2021-02-13
- Rework media items layout - table has been replaced with cards/posters
- Fix bug preventing long folder names from being used as media sources
- Use 24h time pickers in schedule editor
## [0.0.6-prealpha] - 2021-02-12
- Add version information to UI
- Add basic log viewer to UI
## [0.0.5-prealpha] - 2021-02-12
- Fix bug where media scanner could stop prematurely and miss media items
- Add database migrations
## [0.0.4-prealpha] - 2021-02-11
- **Fix HDHomeRun routes** - this version is required to use as a DVR with Plex, older versions will not work
- Improve metadata parsing for tv, add fallback (filename) parsing for movies
## [0.0.3-prealpha] - 2021-02-11
- Fix incomplete XML issue introduced with v0.0.2-prealpha
- Add `.ts` files to local media scanner
- Change M3U, XMLTV, API icons to text links
## 0.0.2-prealpha - 2021-02-11 [YANKED]
- Relax some searches to be case-insensitive
- Improve categorization of tv episodes without sidecar metadata
- Properly escape XML content in XMLTV
## [0.0.1-prealpha] - 2021-02-10
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...HEAD
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
[0.0.54-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
[0.0.53-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.52-alpha...v0.0.53-alpha
[0.0.52-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.51-alpha...v0.0.52-alpha
[0.0.51-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.50-alpha...v0.0.51-alpha
[0.0.50-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.49-prealpha...v0.0.50-alpha
[0.0.49-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.48-prealpha...v0.0.49-prealpha
[0.0.48-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.47-prealpha...v0.0.48-prealpha
[0.0.47-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.46-prealpha...v0.0.47-prealpha
[0.0.46-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.45-prealpha...v0.0.46-prealpha
[0.0.45-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
[0.0.44-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...v0.0.44-prealpha
[0.0.43-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.42-prealpha...v0.0.43-prealpha
[0.0.42-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.41-prealpha...v0.0.42-prealpha
[0.0.41-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.40-prealpha...v0.0.41-prealpha
[0.0.40-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.39-prealpha...v0.0.40-prealpha
[0.0.39-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.38-prealpha...v0.0.39-prealpha
[0.0.38-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.37-prealpha...v0.0.38-prealpha
[0.0.37-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.36-prealpha...v0.0.37-prealpha
[0.0.36-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.35-prealpha...v0.0.36-prealpha
[0.0.35-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.34-prealpha...v0.0.35-prealpha
[0.0.34-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.33-prealpha...v0.0.34-prealpha
[0.0.33-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.32-prealpha...v0.0.33-prealpha
[0.0.32-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.31-prealpha...v0.0.32-prealpha
[0.0.31-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.29-prealpha...v0.0.31-prealpha
[0.0.29-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.28-prealpha...v0.0.29-prealpha
[0.0.28-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.27-prealpha...v0.0.28-prealpha
[0.0.27-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.26-prealpha...v0.0.27-prealpha
[0.0.26-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.25-prealpha...v0.0.26-prealpha
[0.0.25-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.24-prealpha...v0.0.25-prealpha
[0.0.24-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.23-prealpha...v0.0.24-prealpha
[0.0.23-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.22-prealpha...v0.0.23-prealpha
[0.0.22-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.21-prealpha...v0.0.22-prealpha
[0.0.21-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.20-prealpha...v0.0.21-prealpha
[0.0.20-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.19-prealpha...v0.0.20-prealpha
[0.0.19-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.18-prealpha...v0.0.19-prealpha
[0.0.18-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.17-prealpha...v0.0.18-prealpha
[0.0.17-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.16-prealpha...v0.0.17-prealpha
[0.0.16-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.15-prealpha...v0.0.16-prealpha
[0.0.15-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.14-prealpha...v0.0.15-prealpha
[0.0.14-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.13-prealpha...v0.0.14-prealpha
[0.0.13-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.12-prealpha...v0.0.13-prealpha
[0.0.12-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.11-prealpha...v0.0.12-prealpha
[0.0.11-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.10-prealpha...v0.0.11-prealpha
[0.0.10-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.9-prealpha...v0.0.10-prealpha
[0.0.9-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.8-prealpha...v0.0.9-prealpha
[0.0.8-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.7-prealpha...v0.0.8-prealpha
[0.0.7-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.6-prealpha...v0.0.7-prealpha
[0.0.6-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.5-prealpha...v0.0.6-prealpha
[0.0.5-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.4-prealpha...v0.0.5-prealpha
[0.0.4-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.3-prealpha...v0.0.4-prealpha
[0.0.3-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.1-prealpha...v0.0.3-prealpha
[0.0.1-prealpha]: https://github.com/jasongdove/ErsatzTV/releases/tag/v0.0.1-prealpha
@@ -0,0 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Application.MediaItems;
using MediatR;
namespace ErsatzTV.Application.Artists.Queries
{
public record GetAllArtists : IRequest<List<NamedMediaItemViewModel>>;
}
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.Artists.Queries
{
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
{
private readonly IArtistRepository _artistRepository;
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
public Task<List<NamedMediaItemViewModel>> Handle(
GetAllArtists request,
CancellationToken cancellationToken) =>
_artistRepository.GetAllArtists()
.Map(
list => list.Filter(
a => !string.IsNullOrWhiteSpace(
a.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => string.Empty))))
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}
@@ -28,8 +28,9 @@ namespace ErsatzTV.Application.Artists.Queries
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>( return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
async artist => async artist =>
{ {
List<string> languages = await _searchRepository.GetLanguagesForArtist(artist); List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
return ProjectToViewModel(artist, languages); List<string> languageCodes = await _searchRepository.GetAllLanguageCodes(mediaCodes);
return ProjectToViewModel(artist, languageCodes);
}, },
() => Task.FromResult(Option<ArtistViewModel>.None)); () => Task.FromResult(Option<ArtistViewModel>.None));
} }
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Channels
int FFmpegProfileId, int FFmpegProfileId,
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode); StreamingMode StreamingMode,
int? WatermarkId);
} }
@@ -12,5 +12,6 @@ namespace ErsatzTV.Application.Channels.Commands
int FFmpegProfileId, int FFmpegProfileId,
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>; StreamingMode StreamingMode,
int? WatermarkId) : IRequest<Either<BaseError, CreateChannelResult>>;
} }
@@ -7,42 +7,44 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using static ErsatzTV.Application.Channels.Mapper; using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Channels.Commands namespace ErsatzTV.Application.Channels.Commands
{ {
public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseError, ChannelViewModel>> public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
{ {
private readonly IChannelRepository _channelRepository; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
public CreateChannelHandler( public CreateChannelHandler(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
IChannelRepository channelRepository,
IFFmpegProfileRepository ffmpegProfileRepository) public async Task<Either<BaseError, CreateChannelResult>> Handle(
CreateChannel request,
CancellationToken cancellationToken)
{ {
_channelRepository = channelRepository; await using TvContext dbContext = _dbContextFactory.CreateDbContext();
_ffmpegProfileRepository = ffmpegProfileRepository; Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistChannel(dbContext, c));
} }
public Task<Either<BaseError, ChannelViewModel>> Handle( private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
CreateChannel request, {
CancellationToken cancellationToken) => await dbContext.Channels.AddAsync(channel);
Validate(request) await dbContext.SaveChangesAsync();
.MapT(PersistChannel) return new CreateChannelResult(channel.Id);
.Bind(v => v.ToEitherAsync()); }
private Task<ChannelViewModel> PersistChannel(Channel c) => private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
_channelRepository.Add(c).Map(ProjectToViewModel); (ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request),
private async Task<Validation<BaseError, Channel>> Validate(CreateChannel request) => ValidatePreferredLanguage(request),
(ValidateName(request), await ValidateNumber(request), await FFmpegProfileMustExist(request), await WatermarkMustExist(dbContext, request))
ValidatePreferredLanguage(request))
.Apply( .Apply(
(name, number, ffmpegProfileId, preferredLanguageCode) => (name, number, ffmpegProfileId, preferredLanguageCode, watermarkId) =>
{ {
var artwork = new List<Artwork>(); var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo)) if (!string.IsNullOrWhiteSpace(request.Logo))
@@ -57,7 +59,7 @@ namespace ErsatzTV.Application.Channels.Commands
}); });
} }
return new Channel(Guid.NewGuid()) var channel = new Channel(Guid.NewGuid())
{ {
Name = name, Name = name,
Number = number, Number = number,
@@ -66,22 +68,30 @@ namespace ErsatzTV.Application.Channels.Commands
Artwork = artwork, Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode PreferredLanguageCode = preferredLanguageCode
}; };
foreach (int id in watermarkId)
{
channel.WatermarkId = id;
}
return channel;
}); });
private Validation<BaseError, string> ValidateName(CreateChannel createChannel) => private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
createChannel.NotEmpty(c => c.Name) createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name)); .Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
private Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) => private static Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredLanguageCode ?? string.Empty) Optional(createChannel.PreferredLanguageCode ?? string.Empty)
.Filter( .Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any( lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase))) ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid"); .ToValidation<BaseError>("Preferred language code is invalid");
private async Task<Validation<BaseError, string>> ValidateNumber(CreateChannel createChannel) private static async Task<Validation<BaseError, string>> ValidateNumber(TvContext dbContext, CreateChannel createChannel)
{ {
Option<Channel> maybeExistingChannel = await _channelRepository.GetByNumber(createChannel.Number); Option<Channel> maybeExistingChannel = await dbContext.Channels
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number);
return maybeExistingChannel.Match<Validation<BaseError, string>>( return maybeExistingChannel.Match<Validation<BaseError, string>>(
_ => BaseError.New("Channel number must be unique"), _ => BaseError.New("Channel number must be unique"),
() => () =>
@@ -95,9 +105,31 @@ namespace ErsatzTV.Application.Channels.Commands
}); });
} }
private async Task<Validation<BaseError, int>> FFmpegProfileMustExist(CreateChannel createChannel) => private static Task<Validation<BaseError, int>> FFmpegProfileMustExist(
(await _ffmpegProfileRepository.Get(createChannel.FFmpegProfileId)) TvContext dbContext,
.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist.") CreateChannel createChannel) =>
.Map(c => c.Id); dbContext.FFmpegProfiles
.CountAsync(p => p.Id == createChannel.FFmpegProfileId)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => createChannel.FFmpegProfileId)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."));
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
TvContext dbContext,
CreateChannel createChannel)
{
if (createChannel.WatermarkId is null)
{
return Option<int>.None;
}
return await dbContext.ChannelWatermarks
.CountAsync(w => w.Id == createChannel.WatermarkId)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.WatermarkId))
.Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist."));
}
} }
} }
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Channels.Commands
{
public record CreateChannelResult(int ChannelId) : EntityIdResult(ChannelId);
}
@@ -13,5 +13,6 @@ namespace ErsatzTV.Application.Channels.Commands
int FFmpegProfileId, int FFmpegProfileId,
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>; StreamingMode StreamingMode,
int? WatermarkId) : IRequest<Either<BaseError, ChannelViewModel>>;
} }
@@ -7,9 +7,11 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.Mapper; using static ErsatzTV.Application.Channels.Mapper;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@@ -17,28 +19,30 @@ namespace ErsatzTV.Application.Channels.Commands
{ {
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>> public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{ {
private readonly IChannelRepository _channelRepository; private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository; public UpdateChannelHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public Task<Either<BaseError, ChannelViewModel>> Handle( public async Task<Either<BaseError, ChannelViewModel>> Handle(
UpdateChannel request, UpdateChannel request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
Validate(request) {
.MapT(c => ApplyUpdateRequest(c, request)) await using TvContext dbContext = _dbContextFactory.CreateDbContext();
.Bind(v => v.ToEitherAsync()); Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<ChannelViewModel> ApplyUpdateRequest(Channel c, UpdateChannel update) private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
{ {
c.Name = update.Name; c.Name = update.Name;
c.Number = update.Number; c.Number = update.Number;
c.FFmpegProfileId = update.FFmpegProfileId; c.FFmpegProfileId = update.FFmpegProfileId;
c.PreferredLanguageCode = update.PreferredLanguageCode; c.PreferredLanguageCode = update.PreferredLanguageCode;
c.Artwork ??= new List<Artwork>();
if (!string.IsNullOrWhiteSpace(update.Logo)) if (!string.IsNullOrWhiteSpace(update.Logo))
{ {
c.Artwork ??= new List<Artwork>();
Option<Artwork> maybeLogo = Option<Artwork> maybeLogo =
Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo); Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo);
@@ -60,29 +64,40 @@ namespace ErsatzTV.Application.Channels.Commands
c.Artwork.Add(artwork); c.Artwork.Add(artwork);
}); });
} }
c.StreamingMode = update.StreamingMode; c.StreamingMode = update.StreamingMode;
await _channelRepository.Update(c); c.WatermarkId = update.WatermarkId;
await dbContext.SaveChangesAsync();
return ProjectToViewModel(c); return ProjectToViewModel(c);
} }
private async Task<Validation<BaseError, Channel>> Validate(UpdateChannel request) => private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
(await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request), (await ChannelMustExist(dbContext, request), ValidateName(request),
await ValidateNumber(dbContext, request),
ValidatePreferredLanguage(request)) ValidatePreferredLanguage(request))
.Apply((channelToUpdate, _, _, _) => channelToUpdate); .Apply((channelToUpdate, _, _, _) => channelToUpdate);
private Task<Validation<BaseError, Channel>> ChannelMustExist(UpdateChannel updateChannel) => private static Task<Validation<BaseError, Channel>> ChannelMustExist(
_channelRepository.Get(updateChannel.ChannelId) TvContext dbContext,
.Map(v => v.ToValidation<BaseError>("Channel does not exist.")); UpdateChannel updateChannel) =>
dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId)
.Map(o => o.ToValidation<BaseError>("Channel does not exist."));
private Validation<BaseError, string> ValidateName(UpdateChannel updateChannel) => private static Validation<BaseError, string> ValidateName(UpdateChannel updateChannel) =>
updateChannel.NotEmpty(c => c.Name) updateChannel.NotEmpty(c => c.Name)
.Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name)); .Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name));
private async Task<Validation<BaseError, string>> ValidateNumber(UpdateChannel updateChannel) private static async Task<Validation<BaseError, string>> ValidateNumber(
TvContext dbContext,
UpdateChannel updateChannel)
{ {
Option<Channel> match = await _channelRepository.GetByNumber(updateChannel.Number); int matchId = await dbContext.Channels
int matchId = await match.Map(c => c.Id).IfNoneAsync(updateChannel.ChannelId); .SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number)
.Match(c => c.Id, () => updateChannel.ChannelId);
if (matchId == updateChannel.ChannelId) if (matchId == updateChannel.ChannelId)
{ {
if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator)) if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator))
@@ -96,7 +111,7 @@ namespace ErsatzTV.Application.Channels.Commands
return BaseError.New("Channel number must be unique"); return BaseError.New("Channel number must be unique");
} }
private Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) => private static Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) =>
Optional(updateChannel.PreferredLanguageCode ?? string.Empty) Optional(updateChannel.PreferredLanguageCode ?? string.Empty)
.Filter( .Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any( lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
+6 -1
View File
@@ -14,10 +14,15 @@ namespace ErsatzTV.Application.Channels
channel.FFmpegProfileId, channel.FFmpegProfileId,
GetLogo(channel), GetLogo(channel),
channel.PreferredLanguageCode, channel.PreferredLanguageCode,
channel.StreamingMode); channel.StreamingMode,
channel.WatermarkId);
private static string GetLogo(Channel channel) => private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo)) Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
.Match(a => a.Path, string.Empty); .Match(a => a.Path, string.Empty);
private static string GetWatermark(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Watermark))
.Match(a => a.Path, string.Empty);
} }
} }
@@ -3,5 +3,5 @@ using MediatR;
namespace ErsatzTV.Application.Channels.Queries namespace ErsatzTV.Application.Channels.Queries
{ {
public record GetChannelPlaylist(string Scheme, string Host) : IRequest<ChannelPlaylist>; public record GetChannelPlaylist(string Scheme, string Host, string Mode) : IRequest<ChannelPlaylist>;
} }
@@ -1,5 +1,7 @@
using System.Threading; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Iptv;
using LanguageExt; using LanguageExt;
@@ -16,6 +18,31 @@ namespace ErsatzTV.Application.Channels.Queries
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) => public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll() _channelRepository.GetAll()
.Map(channels => EnsureMode(channels, request.Mode))
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels)); .Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels));
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{
var result = new List<Channel>();
foreach (Channel channel in channels)
{
switch (mode.ToLowerInvariant())
{
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);
break;
case "ts":
channel.StreamingMode = StreamingMode.TransportStream;
result.Add(channel);
break;
default:
result.Add(channel);
break;
}
}
return result;
}
} }
} }
@@ -1,6 +1,5 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
@@ -15,19 +14,7 @@ namespace ErsatzTV.Application.Configuration.Commands
public async Task<Unit> Handle(SaveConfigElementByKey request, CancellationToken cancellationToken) public async Task<Unit> Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
{ {
Option<ConfigElement> maybeElement = await _configElementRepository.Get(request.Key); await _configElementRepository.Upsert(request.Key, request.Value);
await maybeElement.Match(
ce =>
{
ce.Value = request.Value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement { Key = request.Key.Key, Value = request.Value };
return _configElementRepository.Add(ce);
});
return Unit.Default; return Unit.Default;
} }
} }
@@ -8,9 +8,8 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Configuration.Commands namespace ErsatzTV.Application.Configuration.Commands
{ {
public class public class UpdateLibraryRefreshIntervalHandler :
UpdateLibraryRefreshIntervalHandler : MediatR.IRequestHandler<UpdateLibraryRefreshInterval, MediatR.IRequestHandler<UpdateLibraryRefreshInterval, Either<BaseError, Unit>>
Either<BaseError, Unit>>
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
@@ -21,27 +20,14 @@ namespace ErsatzTV.Application.Configuration.Commands
UpdateLibraryRefreshInterval request, UpdateLibraryRefreshInterval request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
Validate(request) Validate(request)
.MapT(_ => Upsert(ConfigElementKey.LibraryRefreshInterval, request.LibraryRefreshInterval.ToString())) .MapT(_ => _configElementRepository.Upsert(ConfigElementKey.LibraryRefreshInterval, request.LibraryRefreshInterval))
.Bind(v => v.ToEitherAsync()); .Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) => private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
Optional(request.LibraryRefreshInterval) Optional(request.LibraryRefreshInterval)
.Filter(lri => lri > 0) .Filter(lri => lri > 0)
.Map(_ => Unit.Default) .Map(_ => Unit.Default)
.ToValidation<BaseError>("Tuner count must be greater than zero") .ToValidation<BaseError>("Tuner count must be greater than zero")
.AsTask(); .AsTask();
private Task<Unit> Upsert(ConfigElementKey key, string value) =>
_configElementRepository.Get(key).Match(
ce =>
{
ce.Value = value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement { Key = key.Key, Value = value };
return _configElementRepository.Add(ce);
}).ToUnit();
} }
} }
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Configuration.Commands
{
public record UpdatePlayoutDaysToBuild(int DaysToBuild) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Configuration.Commands
{
public class
UpdatePlayoutDaysToBuildHandler : MediatR.IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdatePlayoutDaysToBuildHandler(
IConfigElementRepository configElementRepository,
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
_configElementRepository = configElementRepository;
_dbContextFactory = dbContextFactory;
_workerChannel = workerChannel;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdatePlayoutDaysToBuild request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Unit> validation = await Validate(request);
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.DaysToBuild));
}
private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild)
{
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild);
// build all playouts to proper number of days
List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Channel)
.ToListAsync();
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
{
await _workerChannel.WriteAsync(new BuildPlayout(playoutId));
}
return Unit.Default;
}
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) =>
Optional(request.DaysToBuild)
.Filter(days => days > 0)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Days to build must be greater than zero")
.AsTask();
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Configuration.Queries
{
public record GetPlayoutDaysToBuild : IRequest<int>;
}
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Configuration.Queries
{
public class GetPlayoutDaysToBuildHandler : IRequestHandler<GetPlayoutDaysToBuild, int>
{
private readonly IConfigElementRepository _configElementRepository;
public GetPlayoutDaysToBuildHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<int> Handle(GetPlayoutDaysToBuild request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
.Map(result => result.IfNone(2));
}
}
@@ -36,6 +36,7 @@ namespace ErsatzTV.Application.Emby.Commands
{ {
List<int> ids = await _mediaSourceRepository.DeleteAllEmby(); List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
await _searchIndex.RemoveItems(ids); await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _embySecretStore.DeleteAll(); await _embySecretStore.DeleteAll();
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>(); _entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@@ -20,17 +21,20 @@ namespace ErsatzTV.Application.Emby.Commands
private readonly IEmbySecretStore _embySecretStore; private readonly IEmbySecretStore _embySecretStore;
private readonly ILogger<SynchronizeEmbyLibrariesHandler> _logger; private readonly ILogger<SynchronizeEmbyLibrariesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
public SynchronizeEmbyLibrariesHandler( public SynchronizeEmbyLibrariesHandler(
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IEmbySecretStore embySecretStore, IEmbySecretStore embySecretStore,
IEmbyApiClient embyApiClient, IEmbyApiClient embyApiClient,
ILogger<SynchronizeEmbyLibrariesHandler> logger) ILogger<SynchronizeEmbyLibrariesHandler> logger,
ISearchIndex searchIndex)
{ {
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_embySecretStore = embySecretStore; _embySecretStore = embySecretStore;
_embyApiClient = embyApiClient; _embyApiClient = embyApiClient;
_logger = logger; _logger = logger;
_searchIndex = searchIndex;
} }
public Task<Either<BaseError, Unit>> Handle( public Task<Either<BaseError, Unit>> Handle(
@@ -75,16 +79,21 @@ namespace ErsatzTV.Application.Emby.Commands
connectionParameters.ApiKey); connectionParameters.ApiKey);
await maybeLibraries.Match( await maybeLibraries.Match(
libraries => async libraries =>
{ {
var existing = connectionParameters.EmbyMediaSource.Libraries.OfType<EmbyLibrary>() var existing = connectionParameters.EmbyMediaSource.Libraries.OfType<EmbyLibrary>()
.ToList(); .ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList(); var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList(); var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
return _mediaSourceRepository.UpdateLibraries( List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.EmbyMediaSource.Id, connectionParameters.EmbyMediaSource.Id,
toAdd, toAdd,
toRemove); toRemove);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
}, },
error => error =>
{ {
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Emby.Commands
private async Task<Unit> Synchronize(RequestParameters parameters) private async Task<Unit> Synchronize(RequestParameters parameters)
{ {
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero); var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6)) DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{ {
switch (parameters.Library.MediaKind) switch (parameters.Library.MediaKind)
{ {
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Emby.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate( private async Task<Validation<BaseError, RequestParameters>> Validate(
ISynchronizeEmbyLibraryById request) => ISynchronizeEmbyLibraryById request) =>
(await ValidateConnection(request), await EmbyLibraryMustExist(request), await ValidateFFprobePath()) (await ValidateConnection(request), await EmbyLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply( .Apply(
(connectionParameters, embyLibrary, ffprobePath) => new RequestParameters( (connectionParameters, embyLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters, connectionParameters,
embyLibrary, embyLibrary,
request.ForceScan, request.ForceScan,
libraryRefreshInterval,
ffprobePath ffprobePath
)); ));
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Emby.Commands
_mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId) _mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId)
.Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist.")); .Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist."));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() => private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath) _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists) .FilterT(File.Exists)
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Emby.Commands
ConnectionParameters ConnectionParameters, ConnectionParameters ConnectionParameters,
EmbyLibrary Library, EmbyLibrary Library,
bool ForceScan, bool ForceScan,
int LibraryRefreshInterval,
string FFprobePath); string FFprobePath);
private record ConnectionParameters( private record ConnectionParameters(
@@ -31,6 +31,7 @@ namespace ErsatzTV.Application.Emby.Commands
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList(); var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
List<int> ids = await _mediaSourceRepository.DisableEmbyLibrarySync(toDisable); List<int> ids = await _mediaSourceRepository.DisableEmbyLibrarySync(toDisable);
await _searchIndex.RemoveItems(ids); await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id); IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
await _mediaSourceRepository.EnableEmbyLibrarySync(toEnable); await _mediaSourceRepository.EnableEmbyLibrarySync(toEnable);
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Emby
{
public record EmbyConnectionParametersViewModel(string Address);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Emby.Queries
{
public record GetEmbyConnectionParameters : IRequest<Either<BaseError, EmbyConnectionParametersViewModel>>;
}
@@ -0,0 +1,73 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
namespace ErsatzTV.Application.Emby.Queries
{
public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnectionParameters,
Either<BaseError, EmbyConnectionParametersViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
public GetEmbyConnectionParametersHandler(
IMemoryCache memoryCache,
IMediaSourceRepository mediaSourceRepository)
{
_memoryCache = memoryCache;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle(
GetEmbyConnectionParameters request,
CancellationToken cancellationToken)
{
if (_memoryCache.TryGetValue(request, out EmbyConnectionParametersViewModel parameters))
{
return parameters;
}
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
await Validate()
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address))
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
return maybeParameters.Match(
p =>
{
_memoryCache.Set(request, p, TimeSpan.FromHours(1));
return maybeParameters;
},
error => error);
}
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
EmbyMediaSourceMustExist()
.BindT(MediaSourceMustHaveActiveConnection);
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
.Map(
v => v.ToValidation<BaseError>(
"Emby media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
EmbyMediaSource embyMediaSource)
{
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.FirstOrDefault();
return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection))
.ToValidation<BaseError>("Emby media source requires an active connection");
}
private record ConnectionParameters(
EmbyMediaSource EmbyMediaSource,
EmbyConnection ActiveConnection);
}
}
+4
View File
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application
{
public record EntityIdResult(int Id);
}
@@ -3,6 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net5.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -12,7 +13,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="MediatR" Version="9.0.0" /> <PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -22,6 +23,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" /> <ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -21,6 +21,5 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
bool NormalizeLoudness, bool NormalizeLoudness,
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeAudio, bool NormalizeAudio) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
} }
@@ -2,39 +2,42 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using static ErsatzTV.Application.FFmpegProfiles.Mapper; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles.Commands namespace ErsatzTV.Application.FFmpegProfiles.Commands
{ {
public class public class CreateFFmpegProfileHandler :
CreateFFmpegProfileHandler : IRequestHandler<CreateFFmpegProfile, Either<BaseError, FFmpegProfileViewModel>> IRequestHandler<CreateFFmpegProfile, Either<BaseError, CreateFFmpegProfileResult>>
{ {
private readonly IFFmpegProfileRepository _ffmpegProfileRepository; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IResolutionRepository _resolutionRepository;
public CreateFFmpegProfileHandler( public CreateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
IFFmpegProfileRepository ffmpegProfileRepository, _dbContextFactory = dbContextFactory;
IResolutionRepository resolutionRepository)
public async Task<Either<BaseError, CreateFFmpegProfileResult>> Handle(
CreateFFmpegProfile request,
CancellationToken cancellationToken)
{ {
_ffmpegProfileRepository = ffmpegProfileRepository; await using TvContext dbContext = _dbContextFactory.CreateDbContext();
_resolutionRepository = resolutionRepository; Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
} }
public Task<Either<BaseError, FFmpegProfileViewModel>> Handle( private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
CreateFFmpegProfile request, TvContext dbContext,
CancellationToken cancellationToken) => FFmpegProfile ffmpegProfile)
Validate(request) {
.MapT(PersistFFmpegProfile) await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile);
.Bind(v => v.ToEitherAsync()); await dbContext.SaveChangesAsync();
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
}
private Task<FFmpegProfileViewModel> PersistFFmpegProfile(FFmpegProfile ffmpegProfile) => private async Task<Validation<BaseError, FFmpegProfile>> Validate(TvContext dbContext, CreateFFmpegProfile request) =>
_ffmpegProfileRepository.Add(ffmpegProfile).Map(ProjectToViewModel); (ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(dbContext, request))
private async Task<Validation<BaseError, FFmpegProfile>> Validate(CreateFFmpegProfile request) =>
(ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(request))
.Apply( .Apply(
(name, threadCount, resolutionId) => new FFmpegProfile (name, threadCount, resolutionId) => new FFmpegProfile
{ {
@@ -53,20 +56,22 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
NormalizeLoudness = request.NormalizeLoudness, NormalizeLoudness = request.NormalizeLoudness,
AudioChannels = request.AudioChannels, AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate, AudioSampleRate = request.AudioSampleRate,
NormalizeAudio = request.NormalizeAudio, NormalizeAudio = request.NormalizeAudio
FrameRate = request.FrameRate
}); });
private Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) => private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.NotEmpty(x => x.Name) createFFmpegProfile.NotEmpty(x => x.Name)
.Bind(_ => createFFmpegProfile.NotLongerThan(50)(x => x.Name)); .Bind(_ => createFFmpegProfile.NotLongerThan(50)(x => x.Name));
private Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) => private static Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.AtLeast(0)(p => p.ThreadCount); createFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private async Task<Validation<BaseError, int>> ResolutionMustExist(CreateFFmpegProfile createFFmpegProfile) => private static Task<Validation<BaseError, int>> ResolutionMustExist(
(await _resolutionRepository.Get(createFFmpegProfile.ResolutionId)) TvContext dbContext,
.ToValidation<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist.") CreateFFmpegProfile createFFmpegProfile) =>
.Map(c => c.Id); dbContext.Resolutions
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId)
.MapT(r => r.Id)
.Map(o => o.ToValidation<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist"));
} }
} }
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public record CreateFFmpegProfileResult(int FFmpegProfileId) : EntityIdResult(FFmpegProfileId);
}
@@ -1,9 +1,7 @@
using System.Threading.Tasks; using ErsatzTV.Core;
using ErsatzTV.Core;
using LanguageExt; using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Commands namespace ErsatzTV.Application.FFmpegProfiles.Commands
{ {
public record DeleteFFmpegProfile(int FFmpegProfileId) : IRequest<Either<BaseError, Task>>; public record DeleteFFmpegProfile(int FFmpegProfileId) : MediatR.IRequest<Either<BaseError, Unit>>;
} }
@@ -1,32 +1,43 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles.Commands namespace ErsatzTV.Application.FFmpegProfiles.Commands
{ {
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Task>> public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, LanguageExt.Unit>>
{ {
private readonly IFFmpegProfileRepository _ffmpegProfileRepository; private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository) => public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_ffmpegProfileRepository = ffmpegProfileRepository; _dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Task>> Handle( public async Task<Either<BaseError, LanguageExt.Unit>> Handle(
DeleteFFmpegProfile request, DeleteFFmpegProfile request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
(await FFmpegProfileMustExist(request)) {
.Map(DoDeletion) await using TvContext dbContext = _dbContextFactory.CreateDbContext();
.ToEither<Task>(); Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
return await validation.Apply(p => DoDeletion(dbContext, p));
}
private Task DoDeletion(int channelId) => _ffmpegProfileRepository.Delete(channelId); private static async Task<LanguageExt.Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
{
dbContext.FFmpegProfiles.Remove(ffmpegProfile);
await dbContext.SaveChangesAsync();
return LanguageExt.Unit.Default;
}
private async Task<Validation<BaseError, int>> FFmpegProfileMustExist( private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
DeleteFFmpegProfile deleteFFmpegProfile) => TvContext dbContext,
(await _ffmpegProfileRepository.Get(deleteFFmpegProfile.FFmpegProfileId)) DeleteFFmpegProfile request) =>
.ToValidation<BaseError>($"FFmpegProfile {deleteFFmpegProfile.FFmpegProfileId} does not exist.") dbContext.FFmpegProfiles
.Map(c => c.Id); .SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
} }
} }
@@ -2,9 +2,11 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
using static ErsatzTV.Application.FFmpegProfiles.Mapper; using static ErsatzTV.Application.FFmpegProfiles.Mapper;
@@ -12,24 +14,21 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
{ {
public class NewFFmpegProfileHandler : IRequestHandler<NewFFmpegProfile, FFmpegProfileViewModel> public class NewFFmpegProfileHandler : IRequestHandler<NewFFmpegProfile, FFmpegProfileViewModel>
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IResolutionRepository _resolutionRepository;
public NewFFmpegProfileHandler( public NewFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
IResolutionRepository resolutionRepository, _dbContextFactory = dbContextFactory;
IConfigElementRepository configElementRepository)
{
_resolutionRepository = resolutionRepository;
_configElementRepository = configElementRepository;
}
public async Task<FFmpegProfileViewModel> Handle(NewFFmpegProfile request, CancellationToken cancellationToken) public async Task<FFmpegProfileViewModel> Handle(NewFFmpegProfile request, CancellationToken cancellationToken)
{ {
int defaultResolutionId = await _configElementRepository await using TvContext dbContext = _dbContextFactory.CreateDbContext();
int defaultResolutionId = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId) .GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId)
.IfNoneAsync(0); .IfNoneAsync(0);
List<Resolution> allResolutions = await _resolutionRepository.GetAll(); List<Resolution> allResolutions = await dbContext.Resolutions
.ToListAsync(cancellationToken);
Option<Resolution> maybeDefaultResolution = allResolutions.Find(r => r.Id == defaultResolutionId); Option<Resolution> maybeDefaultResolution = allResolutions.Find(r => r.Id == defaultResolutionId);
Resolution defaultResolution = maybeDefaultResolution.Match(identity, () => allResolutions.Head()); Resolution defaultResolution = maybeDefaultResolution.Match(identity, () => allResolutions.Head());
@@ -22,6 +22,5 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
bool NormalizeLoudness, bool NormalizeLoudness,
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeAudio, bool NormalizeAudio) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
} }
@@ -2,35 +2,35 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using static ErsatzTV.Application.FFmpegProfiles.Mapper; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles.Commands namespace ErsatzTV.Application.FFmpegProfiles.Commands
{ {
public class public class
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, FFmpegProfileViewModel>> UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
{ {
private readonly IFFmpegProfileRepository _ffmpegProfileRepository; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IResolutionRepository _resolutionRepository;
public UpdateFFmpegProfileHandler( public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
IFFmpegProfileRepository ffmpegProfileRepository, _dbContextFactory = dbContextFactory;
IResolutionRepository resolutionRepository)
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
UpdateFFmpegProfile request,
CancellationToken cancellationToken)
{ {
_ffmpegProfileRepository = ffmpegProfileRepository; await using TvContext dbContext = _dbContextFactory.CreateDbContext();
_resolutionRepository = resolutionRepository; Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
} }
public Task<Either<BaseError, FFmpegProfileViewModel>> Handle( private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
UpdateFFmpegProfile request, TvContext dbContext,
CancellationToken cancellationToken) => FFmpegProfile p,
Validate(request) UpdateFFmpegProfile update)
.MapT(c => ApplyUpdateRequest(c, request))
.Bind(v => v.ToEitherAsync());
private async Task<FFmpegProfileViewModel> ApplyUpdateRequest(FFmpegProfile p, UpdateFFmpegProfile update)
{ {
p.Name = update.Name; p.Name = update.Name;
p.ThreadCount = update.ThreadCount; p.ThreadCount = update.ThreadCount;
@@ -48,31 +48,37 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.AudioChannels = update.AudioChannels; p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate; p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.NormalizeAudio; p.NormalizeAudio = update.NormalizeAudio;
p.FrameRate = update.FrameRate; await dbContext.SaveChangesAsync();
await _ffmpegProfileRepository.Update(p); return new UpdateFFmpegProfileResult(p.Id);
return ProjectToViewModel(p);
} }
private async Task<Validation<BaseError, FFmpegProfile>> Validate(UpdateFFmpegProfile request) => private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
(await FFmpegProfileMustExist(request), ValidateName(request), ValidateThreadCount(request), TvContext dbContext,
await ResolutionMustExist(request)) UpdateFFmpegProfile request) =>
(await FFmpegProfileMustExist(dbContext, request), ValidateName(request), ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request))
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate); .Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
private async Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist( private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile) => UpdateFFmpegProfile updateFFmpegProfile) =>
(await _ffmpegProfileRepository.Get(updateFFmpegProfile.FFmpegProfileId)) dbContext.FFmpegProfiles
.ToValidation<BaseError>("FFmpegProfile does not exist."); .SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId)
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
private Validation<BaseError, string> ValidateName(UpdateFFmpegProfile updateFFmpegProfile) => private static Validation<BaseError, string> ValidateName(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.NotEmpty(x => x.Name) updateFFmpegProfile.NotEmpty(x => x.Name)
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name)); .Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name));
private Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) => private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount); updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private async Task<Validation<BaseError, int>> ResolutionMustExist(UpdateFFmpegProfile updateFFmpegProfile) => private static Task<Validation<BaseError, int>> ResolutionMustExist(
(await _resolutionRepository.Get(updateFFmpegProfile.ResolutionId)) TvContext dbContext,
.ToValidation<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist.") UpdateFFmpegProfile updateFFmpegProfile) =>
.Map(c => c.Id); dbContext.Resolutions
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId)
.MapT(r => r.Id)
.Map(o => o.ToValidation<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist"));
} }
} }
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public record UpdateFFmpegProfileResult(int FFmpegProfileId) : EntityIdResult(FFmpegProfileId);
}
@@ -1,11 +1,13 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Application.FFmpegProfiles.Commands namespace ErsatzTV.Application.FFmpegProfiles.Commands
@@ -14,13 +16,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly IRuntimeInfo _runtimeInfo;
public UpdateFFmpegSettingsHandler( public UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem) ILocalFileSystem localFileSystem,
IRuntimeInfo runtimeInfo)
{ {
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_runtimeInfo = runtimeInfo;
} }
public Task<Either<BaseError, Unit>> Handle( public Task<Either<BaseError, Unit>> Handle(
@@ -31,8 +36,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(v => v.ToEitherAsync()); .Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) => private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
(await FFmpegMustExist(request), await FFprobeMustExist(request)) (await FFmpegMustExist(request), await FFprobeMustExist(request), ReportsAreNotSupportedOnWindows(request))
.Apply((_, _) => Unit.Default); .Apply((_, _, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) => private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg"); ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
@@ -40,6 +45,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) => private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFprobePath, "ffprobe"); ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
private Validation<BaseError, Unit> ReportsAreNotSupportedOnWindows(UpdateFFmpegSettings request)
{
if (request.Settings.SaveReports && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
{
return BaseError.New("FFmpeg reports are not supported on Windows");
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name) private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
{ {
if (!_localFileSystem.FileExists(path)) if (!_localFileSystem.FileExists(path))
@@ -71,35 +86,36 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request) private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
{ {
await Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath); await _configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath);
await Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath); await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath);
await Upsert(ConfigElementKey.FFmpegDefaultProfileId, request.Settings.DefaultFFmpegProfileId.ToString()); await _configElementRepository.Upsert(
await Upsert(ConfigElementKey.FFmpegSaveReports, request.Settings.SaveReports.ToString()); ConfigElementKey.FFmpegDefaultProfileId,
request.Settings.DefaultFFmpegProfileId.ToString());
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegSaveReports,
request.Settings.SaveReports.ToString());
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder)) if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
{ {
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder); Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
} }
await Upsert(ConfigElementKey.FFmpegPreferredLanguageCode, request.Settings.PreferredLanguageCode); await _configElementRepository.Upsert(
ConfigElementKey.FFmpegPreferredLanguageCode,
request.Settings.PreferredLanguageCode);
if (request.Settings.GlobalWatermarkId is not null)
{
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegGlobalWatermarkId,
request.Settings.GlobalWatermarkId.Value);
}
else
{
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
}
return Unit.Default; return Unit.Default;
} }
private async Task Upsert(ConfigElementKey key, string value)
{
Option<ConfigElement> maybeElement = await _configElementRepository.Get(key);
await maybeElement.Match(
ce =>
{
ce.Value = value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement { Key = key.Key, Value = value };
return _configElementRepository.Add(ce);
});
}
} }
} }
@@ -20,6 +20,5 @@ namespace ErsatzTV.Application.FFmpegProfiles
bool NormalizeLoudness, bool NormalizeLoudness,
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeAudio, bool NormalizeAudio);
string FrameRate);
} }
@@ -7,5 +7,6 @@
public int DefaultFFmpegProfileId { get; set; } public int DefaultFFmpegProfileId { get; set; }
public string PreferredLanguageCode { get; set; } public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; } public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; }
} }
} }
@@ -23,8 +23,7 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.NormalizeLoudness, profile.NormalizeLoudness,
profile.AudioChannels, profile.AudioChannels,
profile.AudioSampleRate, profile.AudioSampleRate,
profile.NormalizeAudio, profile.NormalizeAudio);
profile.FrameRate);
private static ResolutionViewModel Project(Resolution resolution) => private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height); new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
@@ -2,22 +2,30 @@
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.FFmpegProfiles.Mapper; using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles.Queries namespace ErsatzTV.Application.FFmpegProfiles.Queries
{ {
public class GetAllFFmpegProfilesHandler : IRequestHandler<GetAllFFmpegProfiles, List<FFmpegProfileViewModel>> public class GetAllFFmpegProfilesHandler : IRequestHandler<GetAllFFmpegProfiles, List<FFmpegProfileViewModel>>
{ {
private readonly IFFmpegProfileRepository _ffmpegProfileRepository; private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllFFmpegProfilesHandler(IFFmpegProfileRepository ffmpegProfileRepository) => public GetAllFFmpegProfilesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_ffmpegProfileRepository = ffmpegProfileRepository; _dbContextFactory = dbContextFactory;
public async Task<List<FFmpegProfileViewModel>> Handle( public async Task<List<FFmpegProfileViewModel>> Handle(
GetAllFFmpegProfiles request, GetAllFFmpegProfiles request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
(await _ffmpegProfileRepository.GetAll()).Map(ProjectToViewModel).ToList(); {
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
} }
} }
@@ -1,23 +1,30 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.FFmpegProfiles.Mapper; using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles.Queries namespace ErsatzTV.Application.FFmpegProfiles.Queries
{ {
public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>> public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>>
{ {
private readonly IFFmpegProfileRepository _ffmpegProfileRepository; private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetFFmpegProfileByIdHandler(IFFmpegProfileRepository ffmpegProfileRepository) => public GetFFmpegProfileByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_ffmpegProfileRepository = ffmpegProfileRepository; _dbContextFactory = dbContextFactory;
public Task<Option<FFmpegProfileViewModel>> Handle( public async Task<Option<FFmpegProfileViewModel>> Handle(
GetFFmpegProfileById request, GetFFmpegProfileById request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
_ffmpegProfileRepository.Get(request.Id) {
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id)
.MapT(ProjectToViewModel); .MapT(ProjectToViewModel);
}
} }
} }
@@ -26,15 +26,24 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports); await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
Option<string> preferredLanguageCode = Option<string> preferredLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode); await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
return new FFmpegSettingsViewModel var result = new FFmpegSettingsViewModel
{ {
FFmpegPath = await ffmpegPath.IfNoneAsync(string.Empty), FFmpegPath = await ffmpegPath.IfNoneAsync(string.Empty),
FFprobePath = await ffprobePath.IfNoneAsync(string.Empty), FFprobePath = await ffprobePath.IfNoneAsync(string.Empty),
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0), DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false), SaveReports = await saveReports.IfNoneAsync(false),
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng") PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
}; };
foreach (int watermarkId in watermark)
{
result.GlobalWatermarkId = watermarkId;
}
return result;
} }
} }
} }
@@ -19,27 +19,14 @@ namespace ErsatzTV.Application.HDHR.Commands
UpdateHDHRTunerCount request, UpdateHDHRTunerCount request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
Validate(request) Validate(request)
.MapT(_ => Upsert(ConfigElementKey.HDHRTunerCount, request.TunerCount.ToString())) .MapT(_ => _configElementRepository.Upsert(ConfigElementKey.HDHRTunerCount, request.TunerCount.ToString()))
.Bind(v => v.ToEitherAsync()); .Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) => private static Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>
Optional(request.TunerCount) Optional(request.TunerCount)
.Filter(tc => tc > 0) .Filter(tc => tc > 0)
.Map(_ => Unit.Default) .Map(_ => Unit.Default)
.ToValidation<BaseError>("Tuner count must be greater than zero") .ToValidation<BaseError>("Tuner count must be greater than zero")
.AsTask(); .AsTask();
private Task<Unit> Upsert(ConfigElementKey key, string value) =>
_configElementRepository.Get(key).Match(
ce =>
{
ce.Value = value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement { Key = key.Key, Value = value };
return _configElementRepository.Add(ce);
}).ToUnit();
} }
} }
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Images
{
public record CachedImagePathViewModel(string FileName, string MimeType);
}
@@ -1,5 +0,0 @@
namespace ErsatzTV.Application.Images
{
// ReSharper disable once SuggestBaseTypeForParameter
public record ImageViewModel(byte[] Contents, string MimeType);
}
@@ -5,6 +5,7 @@ using MediatR;
namespace ErsatzTV.Application.Images.Queries namespace ErsatzTV.Application.Images.Queries
{ {
public record GetImageContents public record GetCachedImagePath
(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<Either<BaseError, ImageViewModel>>; (string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<
Either<BaseError, CachedImagePathViewModel>>;
} }
@@ -0,0 +1,72 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using MediatR;
using Winista.Mime;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Images.Queries
{
public class
GetCachedImagePathHandler : IRequestHandler<GetCachedImagePath, Either<BaseError, CachedImagePathViewModel>>
{
private static readonly MimeTypes MimeTypes = new();
private readonly IImageCache _imageCache;
public GetCachedImagePathHandler(IImageCache imageCache) => _imageCache = imageCache;
public async Task<Either<BaseError, CachedImagePathViewModel>> Handle(
GetCachedImagePath request,
CancellationToken cancellationToken)
{
try
{
MimeType mimeType;
string cachePath = _imageCache.GetPathForImage(
request.FileName,
request.ArtworkKind,
Optional(request.MaxHeight));
if (!File.Exists(cachePath))
{
if (request.MaxHeight.HasValue)
{
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
byte[] contents = await File.ReadAllBytesAsync(originalPath, cancellationToken);
Either<BaseError, byte[]> resizeResult =
await _imageCache.ResizeImage(contents, request.MaxHeight.Value);
resizeResult.IfRight(result => contents = result);
string baseFolder = Path.GetDirectoryName(cachePath);
if (baseFolder != null && !Directory.Exists(baseFolder))
{
Directory.CreateDirectory(baseFolder);
}
await File.WriteAllBytesAsync(cachePath, contents, cancellationToken);
mimeType = new MimeType("image/jpeg");
}
else
{
return BaseError.New($"Artwork does not exist on disk at {cachePath}");
}
}
else
{
mimeType = MimeTypes.GetMimeTypeFromFile(cachePath);
}
return new CachedImagePathViewModel(cachePath, mimeType.Name);
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}
@@ -1,69 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Winista.Mime;
namespace ErsatzTV.Application.Images.Queries
{
public class GetImageContentsHandler : IRequestHandler<GetImageContents, Either<BaseError, ImageViewModel>>
{
private static readonly MimeTypes MimeTypes = new();
private readonly IImageCache _imageCache;
private readonly IMemoryCache _memoryCache;
public GetImageContentsHandler(IImageCache imageCache, IMemoryCache memoryCache)
{
_imageCache = imageCache;
_memoryCache = memoryCache;
}
public async Task<Either<BaseError, ImageViewModel>> Handle(
GetImageContents request,
CancellationToken cancellationToken)
{
try
{
return await _memoryCache.GetOrCreateAsync(
request.FileName,
async entry =>
{
entry.SlidingExpiration = TimeSpan.FromHours(1);
string subfolder = request.FileName.Substring(0, 2);
string baseFolder = request.ArtworkKind switch
{
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
_ => FileSystemLayout.LegacyImageCacheFolder
};
string fileName = Path.Combine(baseFolder, request.FileName);
byte[] contents = await File.ReadAllBytesAsync(fileName, cancellationToken);
if (request.MaxHeight.HasValue)
{
Either<BaseError, byte[]> resizeResult = await _imageCache
.ResizeImage(contents, request.MaxHeight.Value);
resizeResult.IfRight(result => contents = result);
}
MimeType mimeType = MimeTypes.GetMimeType(contents);
return new ImageViewModel(contents, mimeType.Name);
});
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}
@@ -36,6 +36,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
{ {
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin(); List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
await _searchIndex.RemoveItems(ids); await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _jellyfinSecretStore.DeleteAll(); await _jellyfinSecretStore.DeleteAll();
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>(); _entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
@@ -6,6 +6,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -22,17 +23,20 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private readonly IJellyfinSecretStore _jellyfinSecretStore; private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly ILogger<SynchronizeJellyfinLibrariesHandler> _logger; private readonly ILogger<SynchronizeJellyfinLibrariesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
public SynchronizeJellyfinLibrariesHandler( public SynchronizeJellyfinLibrariesHandler(
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IJellyfinSecretStore jellyfinSecretStore, IJellyfinSecretStore jellyfinSecretStore,
IJellyfinApiClient jellyfinApiClient, IJellyfinApiClient jellyfinApiClient,
ILogger<SynchronizeJellyfinLibrariesHandler> logger) ILogger<SynchronizeJellyfinLibrariesHandler> logger,
ISearchIndex searchIndex)
{ {
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_jellyfinSecretStore = jellyfinSecretStore; _jellyfinSecretStore = jellyfinSecretStore;
_jellyfinApiClient = jellyfinApiClient; _jellyfinApiClient = jellyfinApiClient;
_logger = logger; _logger = logger;
_searchIndex = searchIndex;
} }
public Task<Either<BaseError, Unit>> Handle( public Task<Either<BaseError, Unit>> Handle(
@@ -77,16 +81,21 @@ namespace ErsatzTV.Application.Jellyfin.Commands
connectionParameters.ApiKey); connectionParameters.ApiKey);
await maybeLibraries.Match( await maybeLibraries.Match(
libraries => async libraries =>
{ {
var existing = connectionParameters.JellyfinMediaSource.Libraries.OfType<JellyfinLibrary>() var existing = connectionParameters.JellyfinMediaSource.Libraries.OfType<JellyfinLibrary>()
.ToList(); .ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList(); var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList(); var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
return _mediaSourceRepository.UpdateLibraries( List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.JellyfinMediaSource.Id, connectionParameters.JellyfinMediaSource.Id,
toAdd, toAdd,
toRemove); toRemove);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
}, },
error => error =>
{ {
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private async Task<Unit> Synchronize(RequestParameters parameters) private async Task<Unit> Synchronize(RequestParameters parameters)
{ {
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero); var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6)) DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{ {
switch (parameters.Library.MediaKind) switch (parameters.Library.MediaKind)
{ {
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate( private async Task<Validation<BaseError, RequestParameters>> Validate(
ISynchronizeJellyfinLibraryById request) => ISynchronizeJellyfinLibraryById request) =>
(await ValidateConnection(request), await JellyfinLibraryMustExist(request), await ValidateFFprobePath()) (await ValidateConnection(request), await JellyfinLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply( .Apply(
(connectionParameters, jellyfinLibrary, ffprobePath) => new RequestParameters( (connectionParameters, jellyfinLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters, connectionParameters,
jellyfinLibrary, jellyfinLibrary,
request.ForceScan, request.ForceScan,
libraryRefreshInterval,
ffprobePath ffprobePath
)); ));
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Jellyfin.Commands
_mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId) _mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId)
.Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} does not exist.")); .Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} does not exist."));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() => private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath) _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists) .FilterT(File.Exists)
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
ConnectionParameters ConnectionParameters, ConnectionParameters ConnectionParameters,
JellyfinLibrary Library, JellyfinLibrary Library,
bool ForceScan, bool ForceScan,
int LibraryRefreshInterval,
string FFprobePath); string FFprobePath);
private record ConnectionParameters( private record ConnectionParameters(
@@ -31,6 +31,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList(); var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
List<int> ids = await _mediaSourceRepository.DisableJellyfinLibrarySync(toDisable); List<int> ids = await _mediaSourceRepository.DisableJellyfinLibrarySync(toDisable);
await _searchIndex.RemoveItems(ids); await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id); IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
await _mediaSourceRepository.EnableJellyfinLibrarySync(toEnable); await _mediaSourceRepository.EnableJellyfinLibrarySync(toEnable);
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Jellyfin
{
public record JellyfinConnectionParametersViewModel(string Address);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Jellyfin.Queries
{
public record GetJellyfinConnectionParameters : IRequest<Either<BaseError, JellyfinConnectionParametersViewModel>>;
}
@@ -0,0 +1,73 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
namespace ErsatzTV.Application.Jellyfin.Queries
{
public class GetJellyfinConnectionParametersHandler : IRequestHandler<GetJellyfinConnectionParameters,
Either<BaseError, JellyfinConnectionParametersViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
public GetJellyfinConnectionParametersHandler(
IMemoryCache memoryCache,
IMediaSourceRepository mediaSourceRepository)
{
_memoryCache = memoryCache;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<Either<BaseError, JellyfinConnectionParametersViewModel>> Handle(
GetJellyfinConnectionParameters request,
CancellationToken cancellationToken)
{
if (_memoryCache.TryGetValue(request, out JellyfinConnectionParametersViewModel parameters))
{
return parameters;
}
Either<BaseError, JellyfinConnectionParametersViewModel> maybeParameters =
await Validate()
.MapT(cp => new JellyfinConnectionParametersViewModel(cp.ActiveConnection.Address))
.Map(v => v.ToEither<JellyfinConnectionParametersViewModel>());
return maybeParameters.Match(
p =>
{
_memoryCache.Set(request, p, TimeSpan.FromHours(1));
return maybeParameters;
},
error => error);
}
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
JellyfinMediaSourceMustExist()
.BindT(MediaSourceMustHaveActiveConnection);
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist() =>
_mediaSourceRepository.GetAllJellyfin().Map(list => list.HeadOrNone())
.Map(
v => v.ToValidation<BaseError>(
"Jellyfin media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
JellyfinMediaSource jellyfinMediaSource)
{
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.FirstOrDefault();
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
}
private record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection);
}
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Libraries.Commands
{
public record CreateLocalLibrary(string Name, LibraryMediaKind MediaKind, List<string> Paths)
: ILocalLibraryRequest, IRequest<Either<BaseError, LocalLibraryViewModel>>;
}
@@ -0,0 +1,84 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Libraries.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Libraries.Commands
{
public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
IRequestHandler<CreateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IEntityLocker _entityLocker;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IEntityLocker entityLocker,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_entityLocker = entityLocker;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
CreateLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
}
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
TvContext dbContext,
LocalLibrary localLibrary)
{
await dbContext.LocalLibraries.AddAsync(localLibrary);
await dbContext.SaveChangesAsync();
if (_entityLocker.LockLibrary(localLibrary.Id))
{
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
}
return ProjectToViewModel(localLibrary);
}
private static Task<Validation<BaseError, LocalLibrary>> Validate(
TvContext dbContext,
CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
TvContext dbContext,
CreateLocalLibrary request) =>
dbContext.LocalMediaSources
.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
})
.Map(o => o.ToValidation<BaseError>("LocalMediaSource does not exist."));
}
}
@@ -19,8 +19,8 @@ namespace ErsatzTV.Application.Libraries.Commands
{ {
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
public CreateLocalLibraryPathHandler(ILibraryRepository mediaSourceRepository) => public CreateLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
_libraryRepository = mediaSourceRepository; _libraryRepository = libraryRepository;
public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle( public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle(
CreateLocalLibraryPath request, CreateLocalLibraryPath request,
@@ -45,7 +45,6 @@ namespace ErsatzTV.Application.Libraries.Commands
List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId) List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId)
.Map(list => list.Map(c => c.Path).ToList()); .Map(list => list.Map(c => c.Path).ToList());
return Optional(request.Path) return Optional(request.Path)
.Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder))) .Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Path must not belong to another library path"); .ToValidation<BaseError>("Path must not belong to another library path");
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Libraries.Commands
{
public record DeleteLocalLibrary(int LocalLibraryId) : IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Libraries.Commands
{
public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
IRequestHandler<DeleteLocalLibrary, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IDbConnection _dbConnection;
private readonly ISearchIndex _searchIndex;
public DeleteLocalLibraryHandler(
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection,
ISearchIndex searchIndex)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, LocalLibrary> validation = await LocalLibraryMustExist(dbContext, request);
return await validation.Apply(localLibrary => DoDeletion(dbContext, localLibrary));
}
private async Task<Unit> DoDeletion(TvContext dbContext, LocalLibrary localLibrary)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT MediaItem.Id FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = localLibrary.Id })
.Map(result => result.ToList());
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
dbContext.LocalLibraries.Remove(localLibrary);
await dbContext.SaveChangesAsync();
return Unit.Default;
}
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
TvContext dbContext,
DeleteLocalLibrary request) =>
dbContext.LocalLibraries
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.LocalLibraryId)
.Map(o => o.ToValidation<BaseError>($"Local library {request.LocalLibraryId} does not exist."));
}
}
@@ -1,7 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Libraries.Commands
{
public record DeleteLocalLibraryPath(int LocalLibraryPathId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -1,45 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Libraries.Commands
{
public class
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
{
private readonly ILibraryRepository _libraryRepository;
private readonly ISearchIndex _searchIndex;
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
{
_libraryRepository = libraryRepository;
_searchIndex = searchIndex;
}
public Task<Either<BaseError, Unit>> Handle(
DeleteLocalLibraryPath request,
CancellationToken cancellationToken) =>
MediaSourceMustExist(request)
.MapT(DoDeletion)
.Bind(t => t.ToEitherAsync());
private async Task<Unit> DoDeletion(LibraryPath libraryPath)
{
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
await _searchIndex.RemoveItems(ids);
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
.HeadOrNone()
.ToValidation<BaseError>(
$"Local library path {request.LocalLibraryPathId} does not exist.");
}
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Application.Libraries.Commands
{
public interface ILocalLibraryRequest
{
public string Name { get; }
}
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Libraries.Commands
{
public abstract class LocalLibraryHandlerBase
{
protected static Task<Validation<BaseError, LocalLibrary>> NameMustBeValid(
ILocalLibraryRequest request,
LocalLibrary localLibrary) =>
request.NotEmpty(c => c.Name)
.Bind(_ => request.NotLongerThan(50)(c => c.Name))
.Map(_ => localLibrary).AsTask();
protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid(
TvContext dbContext,
LocalLibrary localLibrary,
int? existingLibraryId = null)
{
List<string> 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());
return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path))))
.Filter(length => length == 0)
.Map(_ => localLibrary)
.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);
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Libraries.Commands
{
public record MoveLocalLibraryPath(int LibraryPathId, int TargetLibraryId) : IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Libraries.Commands
{
public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath, Either<BaseError, Unit>>
{
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IDbConnection _dbConnection;
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
public MoveLocalLibraryPathHandler(
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection,
ILogger<MoveLocalLibraryPathHandler> logger)
{
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> Handle(
MoveLocalLibraryPath request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => MovePath(dbContext, parameters));
}
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
{
LibraryPath path = parameters.LibraryPath;
LocalLibrary newLibrary = parameters.Library;
path.LibraryId = newLibrary.Id;
if (await dbContext.SaveChangesAsync() > 0)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT MediaItem.Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
new { LibraryPathId = path.Id })
.Map(result => result.ToList());
foreach (int id in ids)
{
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id);
foreach (MediaItem mediaItem in maybeMediaItem)
{
_logger.LogInformation("Moving item at {Path}", await GetPath(mediaItem));
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { mediaItem });
}
}
}
return Unit.Default;
}
private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
MoveLocalLibraryPath request) =>
(await LibraryPathMustExist(dbContext, request), await LocalLibraryMustExist(dbContext, request))
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
TvContext dbContext,
MoveLocalLibraryPath request) =>
dbContext.LibraryPaths
.Include(lp => lp.Library)
.SelectOneAsync(c => c.Id, c => c.Id == request.LibraryPathId)
.Map(o => o.ToValidation<BaseError>("LibraryPath does not exist."));
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
TvContext dbContext,
MoveLocalLibraryPath request) =>
dbContext.LocalLibraries
.Include(ll => ll.Paths)
.SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId)
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist"));
private async Task<string> GetPath(MediaItem mediaItem) =>
mediaItem switch
{
Movie => await _dbConnection.QuerySingleAsync<string>(
@"SELECT Path FROM MediaFile
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
WHERE MV.MovieId = @Id", new { mediaItem.Id }),
Episode => await _dbConnection.QuerySingleAsync<string>(
@"SELECT Path FROM MediaFile
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
WHERE MV.EpisodeId = @Id", new { mediaItem.Id }),
MusicVideo => await _dbConnection.QuerySingleAsync<string>(
@"SELECT Path FROM MediaFile
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
WHERE MV.MusicVideoId = @Id", new { mediaItem.Id }),
_ => null
};
private record Parameters(LibraryPath LibraryPath, LocalLibrary Library);
}
}
@@ -0,0 +1,12 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Libraries.Commands
{
public record UpdateLocalLibraryPath(int Id, string Path);
public record UpdateLocalLibrary(int Id, string Name, List<UpdateLocalLibraryPath> Paths) : ILocalLibraryRequest,
IRequest<Either<BaseError, LocalLibraryViewModel>>;
}
@@ -0,0 +1,110 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Commands
{
public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
IRequestHandler<UpdateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IEntityLocker _entityLocker;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IEntityLocker entityLocker,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_entityLocker = entityLocker;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
UpdateLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
}
private async Task<LocalLibraryViewModel> UpdateLocalLibrary(TvContext dbContext, Parameters parameters)
{
(LocalLibrary existing, LocalLibrary incoming) = parameters;
existing.Name = incoming.Name;
// toAdd
var toAdd = incoming.Paths
.Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path)))
.ToList();
var toRemove = existing.Paths
.Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path)))
.ToList();
existing.Paths.RemoveAll(toRemove.Contains);
existing.Paths.AddRange(toAdd);
await dbContext.SaveChangesAsync();
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id))
{
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
}
return ProjectToViewModel(existing);
}
private static Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
UpdateLocalLibrary request) =>
LocalLibraryMustExist(dbContext, request)
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
.BindT(
parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
.MapT(_ => parameters));
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
TvContext dbContext,
UpdateLocalLibrary request) =>
dbContext.LocalLibraries
.Include(ll => ll.Paths)
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.Id)
.MapT(
existing =>
{
var incoming = new LocalLibrary
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Id = p.Id, Path = p.Path }).ToList(),
MediaSourceId = existing.Id
};
return new Parameters(existing, incoming);
})
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist."));
private record Parameters(LocalLibrary Existing, LocalLibrary Incoming);
private static string NormalizePath(string path)
{
return Path.GetFullPath(new Uri(path).LocalPath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.ToUpperInvariant();
}
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record CountMediaItemsByLibrary(int LibraryId) : IRequest<int>;
}
@@ -0,0 +1,25 @@
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public class CountMediaItemsByLibraryHandler : IRequestHandler<CountMediaItemsByLibrary, int>
{
private readonly IDbConnection _dbConnection;
public CountMediaItemsByLibraryHandler(IDbConnection dbConnection)
{
_dbConnection = dbConnection;
}
public Task<int> Handle(CountMediaItemsByLibrary request, CancellationToken cancellationToken) =>
_dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { request.LibraryId });
}
}
@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record GetAllLocalLibraries : IRequest<List<LocalLibraryViewModel>>;
}
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Queries
{
public class GetAllLocalLibrariesHandler : IRequestHandler<GetAllLocalLibraries, List<LocalLibraryViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public GetAllLocalLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository;
public Task<List<LocalLibraryViewModel>> Handle(
GetAllLocalLibraries request,
CancellationToken cancellationToken) =>
_libraryRepository.GetAll()
.Map(
list => list
.OfType<LocalLibrary>()
.OrderBy(l => l.MediaKind)
.Map(ProjectToViewModel)
.ToList());
}
}
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Logs
{
public record PagedLogEntriesViewModel(int TotalCount, List<LogEntryViewModel> Page);
}
@@ -1,7 +1,14 @@
using System.Collections.Generic; using System;
using System.Linq.Expressions;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR; using MediatR;
namespace ErsatzTV.Application.Logs.Queries namespace ErsatzTV.Application.Logs.Queries
{ {
public record GetRecentLogEntries : IRequest<List<LogEntryViewModel>>; public record GetRecentLogEntries(int PageNum, int PageSize) : IRequest<PagedLogEntriesViewModel>
{
public Expression<Func<LogEntry, object>> SortExpression { get; set; }
public Option<bool> SortDescending { get; set; }
}
} }
@@ -2,20 +2,46 @@
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Logs.Mapper; using static ErsatzTV.Application.Logs.Mapper;
namespace ErsatzTV.Application.Logs.Queries namespace ErsatzTV.Application.Logs.Queries
{ {
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, List<LogEntryViewModel>> public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, PagedLogEntriesViewModel>
{ {
private readonly ILogRepository _logRepository; private readonly IDbContextFactory<LogContext> _dbContextFactory;
public GetRecentLogEntriesHandler(ILogRepository logRepository) => _logRepository = logRepository; public GetRecentLogEntriesHandler(IDbContextFactory<LogContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public Task<List<LogEntryViewModel>> Handle(GetRecentLogEntries request, CancellationToken cancellationToken) => public async Task<PagedLogEntriesViewModel> Handle(
_logRepository.GetRecentLogEntries().Map(list => list.Map(ProjectToViewModel).ToList()); GetRecentLogEntries request,
CancellationToken cancellationToken)
{
await using LogContext logContext = _dbContextFactory.CreateDbContext();
int count = await logContext.LogEntries.CountAsync(cancellationToken);
IOrderedQueryable<LogEntry> ordered = logContext.LogEntries
.OrderByDescending(le => le.Id);
foreach (bool descending in request.SortDescending)
{
ordered = descending
? logContext.LogEntries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Id)
: logContext.LogEntries.OrderBy(request.SortExpression).ThenByDescending(le => le.Id);
}
List<LogEntryViewModel> page = await ordered
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedLogEntriesViewModel(count, page);
}
} }
} }
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Maintenance.Commands
{
public record DeleteOrphanedArtwork : MediatR.IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
}
@@ -0,0 +1,23 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Maintenance.Commands
{
public class DeleteOrphanedArtworkHandler : MediatR.IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
{
private readonly IArtworkRepository _artworkRepository;
public DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) =>
_artworkRepository = artworkRepository;
public Task<Either<BaseError, Unit>>
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
_artworkRepository.GetOrphanedArtwork()
.Bind(_artworkRepository.Delete)
.Map(_ => Right<BaseError, Unit>(Unit.Default));
}
}
+38 -13
View File
@@ -39,7 +39,8 @@ namespace ErsatzTV.Application.MediaCards
internal static TelevisionEpisodeCardViewModel ProjectToViewModel( internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
EpisodeMetadata episodeMetadata, EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin, Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) => Option<EmbyMediaSource> maybeEmby,
bool isSearchResult) =>
new( new(
episodeMetadata.EpisodeId, episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue, episodeMetadata.ReleaseDate ?? DateTime.MinValue,
@@ -48,12 +49,18 @@ namespace ErsatzTV.Application.MediaCards
() => string.Empty), () => string.Empty),
episodeMetadata.Episode.Season.ShowId, episodeMetadata.Episode.Season.ShowId,
episodeMetadata.Episode.SeasonId, episodeMetadata.Episode.SeasonId,
episodeMetadata.Episode.EpisodeNumber, episodeMetadata.Episode.Season.SeasonNumber,
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, () => 0),
episodeMetadata.Title, episodeMetadata.Title,
episodeMetadata.SortTitle,
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match( episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
em => em.Plot ?? string.Empty, em => em.Plot ?? string.Empty,
() => string.Empty), () => string.Empty),
GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby)); isSearchResult
? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList());
internal static MovieCardViewModel ProjectToViewModel( internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata, MovieMetadata movieMetadata,
@@ -101,7 +108,7 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)) collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby))
.ToList(), .ToList(),
collection.MediaItems.OfType<Episode>() collection.MediaItems.OfType<Episode>()
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby)) .Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false))
.ToList(), .ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(), collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head())) collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
@@ -116,13 +123,13 @@ namespace ErsatzTV.Application.MediaCards
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://")) if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
{ {
artwork = JellyfinUrl.ForArtwork(maybeJellyfin, artwork) artwork = JellyfinUrl.RelativeProxyForArtwork(artwork)
.SetQueryParam("fillHeight", 440); .SetQueryParam("fillHeight", 440);
} }
else if (maybeEmby.IsSome && artwork.StartsWith("emby://")) else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
{ {
artwork = EmbyUrl.ForArtwork(maybeEmby, artwork) artwork = EmbyUrl.RelativeProxyForArtwork(artwork)
.SetQueryParam("fillHeight", 440); .SetQueryParam("maxHeight", 440);
} }
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork); return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork);
@@ -136,6 +143,24 @@ namespace ErsatzTV.Application.MediaCards
private static string GetSeasonName(int number) => private static string GetSeasonName(int number) =>
number == 0 ? "Specials" : $"Season {number}"; number == 0 ? "Specials" : $"Season {number}";
private static string GetEpisodePoster(
EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
Option<SeasonMetadata> maybeSeasonMetadata = episodeMetadata.Episode.Season.SeasonMetadata.HeadOrNone();
return maybeSeasonMetadata.Match(
seasonMetadata => GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
() =>
{
Option<ShowMetadata> maybeShowMetadata =
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone();
return maybeShowMetadata.Match(
showMetadata => GetPoster(showMetadata, maybeJellyfin, maybeEmby),
() => string.Empty);
});
}
private static string GetPoster( private static string GetPoster(
Metadata metadata, Metadata metadata,
Option<JellyfinMediaSource> maybeJellyfin, Option<JellyfinMediaSource> maybeJellyfin,
@@ -146,13 +171,13 @@ namespace ErsatzTV.Application.MediaCards
if (maybeJellyfin.IsSome && poster.StartsWith("jellyfin://")) if (maybeJellyfin.IsSome && poster.StartsWith("jellyfin://"))
{ {
poster = JellyfinUrl.ForArtwork(maybeJellyfin, poster) poster = JellyfinUrl.RelativeProxyForArtwork(poster)
.SetQueryParam("fillHeight", 440); .SetQueryParam("fillHeight", 440);
} }
else if (maybeEmby.IsSome && poster.StartsWith("emby://")) else if (maybeEmby.IsSome && poster.StartsWith("emby://"))
{ {
poster = EmbyUrl.ForArtwork(maybeEmby, poster) poster = EmbyUrl.RelativeProxyForArtwork(poster)
.SetQueryParam("fillHeight", 440); .SetQueryParam("maxHeight", 440);
} }
return poster; return poster;
@@ -168,13 +193,13 @@ namespace ErsatzTV.Application.MediaCards
if (maybeJellyfin.IsSome && thumb.StartsWith("jellyfin://")) if (maybeJellyfin.IsSome && thumb.StartsWith("jellyfin://"))
{ {
thumb = JellyfinUrl.ForArtwork(maybeJellyfin, thumb) thumb = JellyfinUrl.RelativeProxyForArtwork(thumb)
.SetQueryParam("fillHeight", 220); .SetQueryParam("fillHeight", 220);
} }
else if (maybeEmby.IsSome && thumb.StartsWith("emby://")) else if (maybeEmby.IsSome && thumb.StartsWith("emby://"))
{ {
thumb = EmbyUrl.ForArtwork(maybeEmby, thumb) thumb = EmbyUrl.RelativeProxyForArtwork(thumb)
.SetQueryParam("fillHeight", 220); .SetQueryParam("maxHeight", 220);
} }
return thumb; return thumb;
@@ -3,23 +3,26 @@ using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCards.Mapper; using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.MediaCards.Queries namespace ErsatzTV.Application.MediaCards.Queries
{ {
public class GetCollectionCardsHandler : IRequestHandler<GetCollectionCards, public class GetCollectionCardsHandler :
Either<BaseError, CollectionCardResultsViewModel>> IRequestHandler<GetCollectionCards, Either<BaseError, CollectionCardResultsViewModel>>
{ {
private readonly IMediaCollectionRepository _collectionRepository; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
public GetCollectionCardsHandler( public GetCollectionCardsHandler(
IMediaCollectionRepository collectionRepository, IDbContextFactory<TvContext> dbContextFactory,
IMediaSourceRepository mediaSourceRepository) IMediaSourceRepository mediaSourceRepository)
{ {
_collectionRepository = collectionRepository; _dbContextFactory = dbContextFactory;
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
} }
@@ -27,14 +30,57 @@ namespace ErsatzTV.Application.MediaCards.Queries
GetCollectionCards request, GetCollectionCards request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone()); .Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby() Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone()); .Map(list => list.HeadOrNone());
return await _collectionRepository return await dbContext.Collections
.GetCollectionWithItemsUntracked(request.Id) .AsNoTracking()
.Include(c => c.CollectionItems)
.Include(c => c.MediaItems)
.ThenInclude(i => i.LibraryPath)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Season).SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Directors)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Writers)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.SeasonMetadata)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection"))) .Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby)); .MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
} }
@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Search;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper; using static ErsatzTV.Application.MediaCards.Mapper;
@@ -39,9 +40,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
List<TelevisionEpisodeCardViewModel> results = await _televisionRepository List<TelevisionEpisodeCardViewModel> results = await _televisionRepository
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize) .GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize)
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby)).ToList()); .Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby, false)).ToList());
return new TelevisionEpisodeCardResultsViewModel(count, results); return new TelevisionEpisodeCardResultsViewModel(count, results, Option<SearchPageMap>.None);
} }
} }
} }
@@ -1,6 +1,11 @@
using System.Collections.Generic; using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards namespace ErsatzTV.Application.MediaCards
{ {
public record TelevisionEpisodeCardResultsViewModel(int Count, List<TelevisionEpisodeCardViewModel> Cards); public record TelevisionEpisodeCardResultsViewModel(
int Count,
List<TelevisionEpisodeCardViewModel> Cards,
Option<SearchPageMap> PageMap);
} }
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCards namespace ErsatzTV.Application.MediaCards
{ {
@@ -9,13 +10,17 @@ namespace ErsatzTV.Application.MediaCards
string ShowTitle, string ShowTitle,
int ShowId, int ShowId,
int SeasonId, int SeasonId,
int Season,
int Episode, int Episode,
string Title, string Title,
string SortTitle,
string Plot, string Plot,
string Poster) : MediaCardViewModel( string Poster,
List<string> Directors,
List<string> Writers) : MediaCardViewModel(
EpisodeId, EpisodeId,
Title, Title,
$"Episode {Episode}", $"Episode {Episode}",
$"Episode {Episode}", SortTitle,
Poster); Poster);
} }
@@ -5,41 +5,47 @@ using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public class public class AddArtistToCollectionHandler :
AddArtistToCollectionHandler : MediatR.IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>> MediatR.IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>>
{ {
private readonly IArtistRepository _artistRepository;
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
public AddArtistToCollectionHandler( public AddArtistToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
IArtistRepository artistRepository,
ChannelWriter<IBackgroundServiceRequest> channel) ChannelWriter<IBackgroundServiceRequest> channel)
{ {
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository; _mediaCollectionRepository = mediaCollectionRepository;
_artistRepository = artistRepository;
_channel = channel; _channel = channel;
} }
public Task<Either<BaseError, Unit>> Handle( public async Task<Either<BaseError, Unit>> Handle(
AddArtistToCollection request, AddArtistToCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
Validate(request)
.MapT(_ => ApplyAddArtistRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddArtistRequest(AddArtistToCollection request)
{ {
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ArtistId)) await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddArtistRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddArtistRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Artist);
if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, true));
} }
@@ -48,21 +54,27 @@ namespace ErsatzTV.Application.MediaCollections.Commands
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, Unit>> Validate(AddArtistToCollection request) => private static async Task<Validation<BaseError, Parameters>> Validate(
(await CollectionMustExist(request), await ValidateArtist(request)) TvContext dbContext,
.Apply((_, _) => Unit.Default); AddArtistToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateArtist(dbContext, request))
.Apply((collection, artist) => new Parameters(collection, artist));
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddArtistToCollection request) => private static Task<Validation<BaseError, Collection>> CollectionMustExist(
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId) TvContext dbContext,
.MapT(_ => Unit.Default) AddArtistToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Collection does not exist.")); dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateArtist(AddArtistToCollection request) => private static Task<Validation<BaseError, Artist>> ValidateArtist(
LoadArtist(request) TvContext dbContext,
.MapT(_ => Unit.Default) AddArtistToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Music video does not exist")); dbContext.Artists
.SelectOneAsync(a => a.Id, a => a.Id == request.ArtistId)
.Map(o => o.ToValidation<BaseError>("Artist does not exist"));
private Task<Option<Artist>> LoadArtist(AddArtistToCollection request) => private record Parameters(Collection Collection, Artist Artist);
_artistRepository.GetArtist(request.ArtistId);
} }
} }
@@ -3,42 +3,49 @@ using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public class public class AddEpisodeToCollectionHandler :
AddEpisodeToCollectionHandler : MediatR.IRequestHandler<AddEpisodeToCollection, Either<BaseError, Unit>> MediatR.IRequestHandler<AddEpisodeToCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddEpisodeToCollectionHandler( public AddEpisodeToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel) ChannelWriter<IBackgroundServiceRequest> channel)
{ {
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository; _mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel; _channel = channel;
} }
public Task<Either<BaseError, Unit>> Handle( public async Task<Either<BaseError, Unit>> Handle(
AddEpisodeToCollection request, AddEpisodeToCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
Validate(request)
.MapT(_ => ApplyAddTelevisionEpisodeRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddTelevisionEpisodeRequest(AddEpisodeToCollection request)
{ {
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.EpisodeId)) await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddTelevisionEpisodeRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddTelevisionEpisodeRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Episode);
if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, true));
} }
@@ -47,21 +54,27 @@ namespace ErsatzTV.Application.MediaCollections.Commands
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, Unit>> Validate(AddEpisodeToCollection request) => private static async Task<Validation<BaseError, Parameters>> Validate(
(await CollectionMustExist(request), await ValidateEpisode(request)) TvContext dbContext,
.Apply((_, _) => Unit.Default); AddEpisodeToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateEpisode(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddEpisodeToCollection request) => private static Task<Validation<BaseError, Collection>> CollectionMustExist(
_mediaCollectionRepository.Get(request.CollectionId) TvContext dbContext,
.MapT(_ => Unit.Default) AddEpisodeToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Collection does not exist.")); dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateEpisode(AddEpisodeToCollection request) => private static Task<Validation<BaseError, Episode>> ValidateEpisode(
LoadTelevisionEpisode(request) TvContext dbContext,
.MapT(_ => Unit.Default) AddEpisodeToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Episode does not exist")); dbContext.Episodes
.SelectOneAsync(e => e.Id, e => e.Id == request.EpisodeId)
.Map(o => o.ToValidation<BaseError>("Episode does not exist"));
private Task<Option<int>> LoadTelevisionEpisode(AddEpisodeToCollection request) => private record Parameters(Collection Collection, Episode Episode);
_televisionRepository.GetEpisode(request.EpisodeId).MapT(e => e.Id);
} }
} }
@@ -9,6 +9,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
int CollectionId, int CollectionId,
List<int> MovieIds, List<int> MovieIds,
List<int> ShowIds, List<int> ShowIds,
List<int> EpisodeIds,
List<int> ArtistIds, List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>; List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
} }
@@ -1,51 +1,70 @@
using System.Linq; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Channels; using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public class public class AddItemsToCollectionHandler :
AddItemsToCollectionHandler : MediatR.IRequestHandler<AddItemsToCollection, Either<BaseError, Unit>> MediatR.IRequestHandler<AddItemsToCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMovieRepository _movieRepository; private readonly IMovieRepository _movieRepository;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
public AddItemsToCollectionHandler( public AddItemsToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
IMovieRepository movieRepository, IMovieRepository movieRepository,
ITelevisionRepository televisionRepository, ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel) ChannelWriter<IBackgroundServiceRequest> channel)
{ {
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository; _mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository; _movieRepository = movieRepository;
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_channel = channel; _channel = channel;
} }
public Task<Either<BaseError, Unit>> Handle( public async Task<Either<BaseError, Unit>> Handle(
AddItemsToCollection request, AddItemsToCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
Validate(request) {
.MapT(_ => ApplyAddItemsRequest(request)) await using TvContext dbContext = _dbContextFactory.CreateDbContext();
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddItemsRequest(AddItemsToCollection request) Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request));
}
private async Task<Unit> ApplyAddItemsRequest(TvContext dbContext, Collection collection, AddItemsToCollection request)
{ {
var allItems = request.MovieIds var allItems = request.MovieIds
.Append(request.ShowIds) .Append(request.ShowIds)
.Append(request.EpisodeIds)
.Append(request.ArtistIds) .Append(request.ArtistIds)
.Append(request.MusicVideoIds) .Append(request.MusicVideoIds)
.ToList(); .ToList();
if (await _mediaCollectionRepository.AddMediaItems(request.CollectionId, allItems)) var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
List<MediaItem> toAdd = await dbContext.MediaItems
.Filter(mi => toAddIds.Contains(mi.Id))
.ToListAsync();
collection.MediaItems.AddRange(toAdd);
if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
@@ -58,14 +77,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, Unit>> Validate(AddItemsToCollection request) => private async Task<Validation<BaseError, Collection>> Validate(TvContext dbContext, AddItemsToCollection request) =>
(await CollectionMustExist(request), await ValidateMovies(request), await ValidateShows(request)) (await CollectionMustExist(dbContext, request),
.Apply((_, _, _) => Unit.Default); await ValidateMovies(request),
await ValidateShows(request),
await ValidateEpisodes(request))
.Apply((collection, _, _, _) => collection);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddItemsToCollection request) => private static Task<Validation<BaseError, Collection>> CollectionMustExist(
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId) TvContext dbContext,
.MapT(_ => Unit.Default) AddItemsToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Collection does not exist.")); dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) => private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) =>
_movieRepository.AllMoviesExist(request.MovieIds) _movieRepository.AllMoviesExist(request.MovieIds)
@@ -80,5 +105,12 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Filter(v => v == true) .Filter(v => v == true)
.MapT(_ => Unit.Default) .MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist")); .Map(v => v.ToValidation<BaseError>("Show does not exist"));
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) =>
_televisionRepository.AllEpisodesExist(request.EpisodeIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
} }
} }
@@ -5,41 +5,47 @@ using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public class public class AddMovieToCollectionHandler :
AddMovieToCollectionHandler : MediatR.IRequestHandler<AddMovieToCollection, Either<BaseError, Unit>> MediatR.IRequestHandler<AddMovieToCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMovieRepository _movieRepository;
public AddMovieToCollectionHandler( public AddMovieToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
IMovieRepository movieRepository,
ChannelWriter<IBackgroundServiceRequest> channel) ChannelWriter<IBackgroundServiceRequest> channel)
{ {
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository; _mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository;
_channel = channel; _channel = channel;
} }
public Task<Either<BaseError, Unit>> Handle( public async Task<Either<BaseError, Unit>> Handle(
AddMovieToCollection request, AddMovieToCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
Validate(request)
.MapT(_ => ApplyAddMoviesRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddMoviesRequest(AddMovieToCollection request)
{ {
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MovieId)) await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddMovieRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Movie);
if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, true));
} }
@@ -48,21 +54,27 @@ namespace ErsatzTV.Application.MediaCollections.Commands
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, Unit>> Validate(AddMovieToCollection request) => private static async Task<Validation<BaseError, Parameters>> Validate(
(await CollectionMustExist(request), await ValidateMovies(request)) TvContext dbContext,
.Apply((_, _) => Unit.Default); AddMovieToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateMovie(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddMovieToCollection request) => private static Task<Validation<BaseError, Collection>> CollectionMustExist(
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId) TvContext dbContext,
.MapT(_ => Unit.Default) AddMovieToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Collection does not exist.")); dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMovies(AddMovieToCollection request) => private static Task<Validation<BaseError, Movie>> ValidateMovie(
LoadMovie(request) TvContext dbContext,
.MapT(_ => Unit.Default) AddMovieToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Movie does not exist")); dbContext.Movies
.SelectOneAsync(m => m.Id, e => e.Id == request.MovieId)
.Map(o => o.ToValidation<BaseError>("Movie does not exist"));
private Task<Option<Movie>> LoadMovie(AddMovieToCollection request) => private record Parameters(Collection Collection, Movie Movie);
_movieRepository.GetMovie(request.MovieId);
} }
} }
@@ -5,41 +5,47 @@ using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public class public class AddMusicVideoToCollectionHandler :
AddMusicVideoToCollectionHandler : MediatR.IRequestHandler<AddMusicVideoToCollection, Either<BaseError, Unit>> MediatR.IRequestHandler<AddMusicVideoToCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
public AddMusicVideoToCollectionHandler( public AddMusicVideoToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
IMusicVideoRepository musicVideoRepository,
ChannelWriter<IBackgroundServiceRequest> channel) ChannelWriter<IBackgroundServiceRequest> channel)
{ {
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository; _mediaCollectionRepository = mediaCollectionRepository;
_musicVideoRepository = musicVideoRepository;
_channel = channel; _channel = channel;
} }
public Task<Either<BaseError, Unit>> Handle( public async Task<Either<BaseError, Unit>> Handle(
AddMusicVideoToCollection request, AddMusicVideoToCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
Validate(request)
.MapT(_ => ApplyAddMusicVideoRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddMusicVideoRequest(AddMusicVideoToCollection request)
{ {
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MusicVideoId)) await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddMusicVideoRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddMusicVideoRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.MusicVideo);
if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, true));
} }
@@ -48,21 +54,27 @@ namespace ErsatzTV.Application.MediaCollections.Commands
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, Unit>> Validate(AddMusicVideoToCollection request) => private static async Task<Validation<BaseError, Parameters>> Validate(
(await CollectionMustExist(request), await ValidateMusicVideo(request)) TvContext dbContext,
.Apply((_, _) => Unit.Default); AddMusicVideoToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateMusicVideo(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddMusicVideoToCollection request) => private static Task<Validation<BaseError, Collection>> CollectionMustExist(
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId) TvContext dbContext,
.MapT(_ => Unit.Default) AddMusicVideoToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Collection does not exist.")); dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMusicVideo(AddMusicVideoToCollection request) => private static Task<Validation<BaseError, MusicVideo>> ValidateMusicVideo(
LoadMusicVideo(request) TvContext dbContext,
.MapT(_ => Unit.Default) AddMusicVideoToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Music video does not exist")); dbContext.MusicVideos
.SelectOneAsync(m => m.Id, e => e.Id == request.MusicVideoId)
.Map(o => o.ToValidation<BaseError>("MusicVideo does not exist"));
private Task<Option<MusicVideo>> LoadMusicVideo(AddMusicVideoToCollection request) => private record Parameters(Collection Collection, MusicVideo MusicVideo);
_musicVideoRepository.GetMusicVideo(request.MusicVideoId);
} }
} }
@@ -5,41 +5,47 @@ using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public class public class AddSeasonToCollectionHandler :
AddSeasonToCollectionHandler : MediatR.IRequestHandler<AddSeasonToCollection, Either<BaseError, Unit>> MediatR.IRequestHandler<AddSeasonToCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddSeasonToCollectionHandler( public AddSeasonToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel) ChannelWriter<IBackgroundServiceRequest> channel)
{ {
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository; _mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel; _channel = channel;
} }
public Task<Either<BaseError, Unit>> Handle( public async Task<Either<BaseError, Unit>> Handle(
AddSeasonToCollection request, AddSeasonToCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
Validate(request)
.MapT(_ => ApplyAddTelevisionSeasonRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddTelevisionSeasonRequest(AddSeasonToCollection request)
{ {
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.SeasonId)) await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddSeasonRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Season);
if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, true));
} }
@@ -48,22 +54,27 @@ namespace ErsatzTV.Application.MediaCollections.Commands
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, Unit>> Validate(AddSeasonToCollection request) => private static async Task<Validation<BaseError, Parameters>> Validate(
(await CollectionMustExist(request), await ValidateSeason(request)) TvContext dbContext,
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddSeasonToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateSeason(AddSeasonToCollection request) =>
LoadTelevisionSeason(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Season does not exist"));
private Task<Option<Season>> LoadTelevisionSeason(
AddSeasonToCollection request) => AddSeasonToCollection request) =>
_televisionRepository.GetSeason(request.SeasonId); (await CollectionMustExist(dbContext, request), await ValidateSeason(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext,
AddSeasonToCollection request) =>
dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private static Task<Validation<BaseError, Season>> ValidateSeason(
TvContext dbContext,
AddSeasonToCollection request) =>
dbContext.Seasons
.SelectOneAsync(m => m.Id, e => e.Id == request.SeasonId)
.Map(o => o.ToValidation<BaseError>("Season does not exist"));
private record Parameters(Collection Collection, Season Season);
} }
} }
@@ -5,65 +5,76 @@ using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public class AddShowToCollectionHandler : MediatR.IRequestHandler<AddShowToCollection, Either<BaseError, Unit>> public class AddShowToCollectionHandler :
MediatR.IRequestHandler<AddShowToCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddShowToCollectionHandler( public AddShowToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel) ChannelWriter<IBackgroundServiceRequest> channel)
{ {
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository; _mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel; _channel = channel;
} }
public Task<Either<BaseError, Unit>> Handle( public async Task<Either<BaseError, Unit>> Handle(
AddShowToCollection request, AddShowToCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
Validate(request)
.MapT(_ => ApplyAddTelevisionShowRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddTelevisionShowRequest(AddShowToCollection request)
{ {
var result = new Unit(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddShowRequest(dbContext, parameters));
}
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ShowId)) private async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Show);
if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, true));
} }
} }
return result; return Unit.Default;
} }
private async Task<Validation<BaseError, Unit>> Validate(AddShowToCollection request) => private static async Task<Validation<BaseError, Parameters>> Validate(
(await CollectionMustExist(request), await ValidateShow(request)) TvContext dbContext,
.Apply((_, _) => Unit.Default); AddShowToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateShow(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddShowToCollection request) => private static Task<Validation<BaseError, Collection>> CollectionMustExist(
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId) TvContext dbContext,
.MapT(_ => Unit.Default) AddShowToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Collection does not exist.")); dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateShow(AddShowToCollection request) => private static Task<Validation<BaseError, Show>> ValidateShow(
LoadTelevisionShow(request) TvContext dbContext,
.MapT(_ => Unit.Default) AddShowToCollection request) =>
.Map(v => v.ToValidation<BaseError>("Show does not exist")); dbContext.Shows
.SelectOneAsync(m => m.Id, e => e.Id == request.ShowId)
.Map(o => o.ToValidation<BaseError>("Show does not exist"));
private Task<Option<Show>> LoadTelevisionShow(AddShowToCollection request) => private record Parameters(Collection Collection, Show Show);
_televisionRepository.GetShow(request.ShowId);
} }
} }
@@ -1,45 +1,60 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper; using static ErsatzTV.Application.MediaCollections.Mapper;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public class public class CreateCollectionHandler :
CreateCollectionHandler : IRequestHandler<CreateCollection, Either<BaseError, MediaCollectionViewModel>> IRequestHandler<CreateCollection, Either<BaseError, MediaCollectionViewModel>>
{ {
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) => public CreateCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_mediaCollectionRepository = mediaCollectionRepository; _dbContextFactory = dbContextFactory;
public Task<Either<BaseError, MediaCollectionViewModel>> Handle( public async Task<Either<BaseError, MediaCollectionViewModel>> Handle(
CreateCollection request, CreateCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
Validate(request).MapT(PersistCollection).Bind(v => v.ToEitherAsync()); {
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private Task<MediaCollectionViewModel> PersistCollection(Collection c) => private static async Task<MediaCollectionViewModel> PersistCollection(
_mediaCollectionRepository.Add(c).Map(ProjectToViewModel); TvContext dbContext,
Collection collection)
{
await dbContext.Collections.AddAsync(collection);
await dbContext.SaveChangesAsync();
return ProjectToViewModel(collection);
}
private Task<Validation<BaseError, Collection>> Validate(CreateCollection request) => private static Task<Validation<BaseError, Collection>> Validate(
ValidateName(request).MapT( TvContext dbContext,
CreateCollection request) =>
ValidateName(dbContext, request).MapT(
name => new Collection name => new Collection
{ {
Name = name, Name = name,
MediaItems = new List<MediaItem>() MediaItems = new List<MediaItem>()
}); });
private async Task<Validation<BaseError, string>> ValidateName(CreateCollection createCollection) private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
CreateCollection createCollection)
{ {
List<string> allNames = await _mediaCollectionRepository.GetAll() List<string> allNames = await dbContext.Collections
.Map(list => list.Map(c => c.Name).ToList()); .Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name) Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name)
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name)); .Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
@@ -0,0 +1,13 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollection
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;
}
@@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class CreateMultiCollectionHandler :
IRequestHandler<CreateMultiCollection, Either<BaseError, MultiCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, MultiCollectionViewModel>> Handle(
CreateMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<MultiCollectionViewModel> PersistCollection(
TvContext dbContext,
MultiCollection multiCollection)
{
await dbContext.MultiCollections.AddAsync(multiCollection);
await dbContext.SaveChangesAsync();
await dbContext.Entry(multiCollection)
.Collection(c => c.MultiCollectionItems)
.Query()
.Include(i => i.Collection)
.LoadAsync();
return ProjectToViewModel(multiCollection);
}
private static Task<Validation<BaseError, MultiCollection>> Validate(
TvContext dbContext,
CreateMultiCollection request) =>
ValidateName(dbContext, request).MapT(
name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
}).ToList()
});
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
CreateMultiCollection createMultiCollection)
{
List<string> allNames = await dbContext.MultiCollections
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createMultiCollection.NotEmpty(c => c.Name)
.Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createMultiCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique");
return (result1, result2).Apply((_, _) => createMultiCollection.Name);
}
}
}
@@ -1,9 +1,8 @@
using System.Threading.Tasks; using ErsatzTV.Core;
using ErsatzTV.Core;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public record DeleteCollection(int CollectionId) : IRequest<Either<BaseError, Task>>; public record DeleteCollection(int CollectionId) : IRequest<Either<BaseError, LanguageExt.Unit>>;
} }

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