Compare commits

...
Author SHA1 Message Date
Jason Dove 11f90f5d44 update changelog for release v0.2.2-alpha [no ci] 2021-10-30 17:49:30 -05:00
Jason DoveandGitHub bda4117655 allow per-episode folders in local show libraries (#462)
* allow per-episode folders in local show libraries

* fix subfolder etag generation
2021-10-30 12:54:07 -05:00
Jason Dove 3240703840 fix build 2021-10-30 12:24:27 -05:00
Jason DoveandGitHub 53a7570ba3 fix epg for multiple playout mode (#461) 2021-10-30 12:16:39 -05:00
Jason DoveandGitHub 0e789fd6d8 update dependencies and fix languageext deprecation warnings (#460) 2021-10-30 11:57:50 -05:00
Jason DoveandGitHub 0136de700c add global and channel fallback filler (#459)
* configure channel and global fallback filler

* play random item from configured channel/global fallback filler as needed
2021-10-30 11:45:40 -05:00
Jason DoveandGitHub 2ea0e64ac1 fix duration schedule item epg (#455) 2021-10-24 22:00:21 -05:00
Jason Dove 5993f23ec5 update changelog for release v0.2.1-alpha [no ci] 2021-10-24 19:46:33 -05:00
Jason DoveandGitHub 417f35a834 fix saving dynamic start time (#453) 2021-10-24 18:18:22 -05:00
Jason DoveandGitHub a74547997d scheduling fixes (#451)
* scheduling fixes

* restore plex service

* restore plex service part 2
2021-10-24 06:49:35 -05:00
Jason Dove a2f74dd284 update changelog for release v0.2.0-alpha [no ci] 2021-10-23 20:18:01 -05:00
Jason Dove 373daf9ce6 add basic filler docs [no docker] 2021-10-23 20:14:10 -05:00
Jason Dove 68693cffa0 use info log level for search index migration 2021-10-21 20:43:17 -05:00
Jason DoveandGitHub 6d147de2f3 filler rework (#449)
* add chapter statistics and new filler options

* refactor playout builder

* more refactor prep for filler

* rewrite schedulers

* refactor collectionkey

* add tail filler kind

* migrate tail filler to filler preset

* optionally show filler

* fix playout detail row count

* remove duration tail filler options

* implement tail and fallback in flood scheduler

* implement tail and fallback in one scheduler

* implement tail and fallback in multiple scheduler

* implement looping fallback filler

* more duration tests

* start to add post-roll filler to flood

* rework playoutitem filler tagging

* rework scheduler logging

* calculate whether configured filler will fit

* implement pre-roll and post-roll duration and count filler

* improve duration filler calculation

* add minutes to search index

* update channel guide to work with new filler

* add mid-roll filler

* don't clone enumerators for filler calculations

* support pre-roll and post-roll pad filler

* implement mid-roll pad filler

* allow clearing filler selections in schedule editor

* fix tests

* filler config validation

* use consistent time zone for tests
2021-10-21 20:23:14 -05:00
Jason DoveandGitHub f4a63a1a1a fix deleting jellyfin and emby movies (#447)
* fix deleting jellyfin and emby movies

* revert jf service change
2021-10-19 14:33:48 -05:00
Jason DoveandGitHub bc9d17ca25 show path replacement logs by default (#445) 2021-10-19 06:32:00 -05:00
Jason DoveandGitHub 42e13cbbaf fix generated streams with mpeg2video (#444) 2021-10-18 19:51:50 -05:00
Jason Dove 6cc61f3212 update changelog for release v0.1.5-alpha [no ci] 2021-10-18 17:55:34 -05:00
Jason DoveandGitHub 4cf44616a8 Include music video thumbnail in epg (#443)
* include music video thumbnail in epg

* update changelog
2021-10-18 17:53:58 -05:00
Jason DoveandGitHub 33aaadae68 multiple fixes to duration mode (#442) 2021-10-18 16:21:32 -05:00
Jason DoveandGitHub fe3f8e391e fix updating jellyfin and emby artwork (#440) 2021-10-17 05:36:00 -05:00
Jason DoveandGitHub 1a68dd040a find working plex connection on startup (#438) 2021-10-16 11:55:54 -05:00
Jason DoveandGitHub 67761c1a14 fix updating jellyfin and emby tv seasons (#437)
* fix updating jellyfin and emby tv seasons

* update changelog
2021-10-16 09:05:08 -05:00
Jason DoveandGitHub 1802f9d797 fix database migration (#436) 2021-10-15 11:31:40 -05:00
Jason DoveandGitHub 69354c9296 fix double scheduling (#435) 2021-10-15 09:14:53 -05:00
Jason Dove 0021e21b50 fix other video playback 2021-10-14 22:53:32 -05:00
Jason Dove cdf7765059 update changelog for release v0.1.4-alpha [no ci] 2021-10-14 15:00:24 -05:00
Jason DoveandGitHub 71658c448f update docs (#431) 2021-10-14 14:41:12 -05:00
Jason DoveandGitHub 3ecdd741a5 add guide mode to schedule items (#430) 2021-10-14 13:24:54 -05:00
Jason DoveandGitHub 0daeb844b9 add other videos library kind (#429) 2021-10-14 12:58:37 -05:00
Jason DoveandGitHub 22da19845b add filler option to duration playout mode (#428)
* add duration tail options to schedule items editor

* add naive filler scheduling

* fix duration item length in xmltv

* show offline image for unfilled duration tail

* fix tests

* update changelog

* update dependencies
2021-10-13 21:15:16 -05:00
Jason Dove 3a6d9e9f39 update changelog for release v0.1.3-alpha [no ci] 2021-10-13 15:17:23 -05:00
Jason DoveandGitHub 7ed4b8ae3c fix startup bug (#426) 2021-10-13 13:30:26 -05:00
Jason Dove be7311e620 update changelog for release v0.1.2-alpha [no ci] 2021-10-12 19:00:30 -05:00
Jason Dove 03be372070 update changelog [no ci] 2021-10-12 18:57:02 -05:00
Jason DoveandGitHub d196308ee9 fix ffmpeg profile editing (#421) 2021-10-12 18:39:51 -05:00
Jason Dove 3d68b0f055 fix vaapi migration 2021-10-12 18:22:09 -05:00
Jason Dove 37e32f06ad update changelog [no ci] 2021-10-12 17:56:56 -05:00
Jason DoveandGitHub c43ca2837d support radeon vaapi acceleration (#420) 2021-10-12 17:51:55 -05:00
Jason DoveandGitHub 992121f308 add more watermark locations (#419) 2021-10-12 07:19:52 -05:00
Jason DoveandGitHub 04adbfeffa add hls segmenter settings to optimize performance (#418)
* add hls segmenter settings to optimize performance

* use consistent setting defaults
2021-10-12 06:31:11 -05:00
Jason DoveandGitHub 1fc905c6ad upgrade vaapi to ffmpeg 4.4 (#417) 2021-10-11 22:26:32 -05:00
Jason DoveandGitHub 4b5dff2159 ffnvcodec fixes (#416) 2021-10-11 22:14:43 -05:00
Jason DoveandGitHub 2a5edf8214 ffmpeg 4.4 llvm nvidia fixes (#415) 2021-10-11 21:31:59 -05:00
Jason DoveandGitHub 69912c8cae support ffmpeg 4.4 (#414)
* support ffmpeg 4.4

* update changelog
2021-10-11 20:16:15 -05:00
Jason DoveandGitHub fd3de2d82a nvidia 10 bit fixes (#413) 2021-10-11 16:00:35 -05:00
Jason DoveandGitHub 6ba9404752 nvidia transcoding improvements (#412)
* nvidia transcoding fixes

* use yadif_cuda to deinterlace
2021-10-10 22:40:43 -05:00
Jason Dove db080375c5 update changelog for release v0.1.1-alpha [no ci] 2021-10-10 12:54:46 -05:00
Jason Dove 9abc7ad8b7 try to fix tests on windows 2021-10-10 12:39:08 -05:00
Jason DoveandGitHub 9e531a82d7 add some hls playlist filter tests (#411) 2021-10-10 12:33:02 -05:00
Jason DoveandGitHub d84bd2b948 upgrade nvidia docker image (#410) 2021-10-10 11:45:02 -05:00
Jason DoveandGitHub d7d3ec1235 add music video album to search index (#409)
* add music video album to search index

* update search docs
2021-10-10 10:28:35 -05:00
Jason Dove 742ac21ad7 update collection docs [no docker] 2021-10-10 07:53:29 -05:00
Jason DoveandGitHub 819b55e21f increase max hls segments (#408) 2021-10-10 06:47:24 -05:00
Jason DoveandGitHub cf5718c288 rework hls segmenter (#407)
* rework hls segmenter to start more quickly

* don't use realtime encoding for hls until we're at least a minute ahead

* ugly but functional playlist filtering
2021-10-09 22:46:38 -05:00
Jason DoveandGitHub adc7982955 reduce initial hls segmenter delay (#406) 2021-10-09 10:26:57 -05:00
Jason Dove 67a6f554d0 rename v0.0.63-alpha v0.1.0-alpha [no ci] 2021-10-08 18:44:49 -05:00
Jason Dove 609df217ae update changelog for release 63 [no ci] 2021-10-08 18:39:03 -05:00
Jason DoveandGitHub d3086264c7 unraid doc fixes (#405) 2021-10-08 18:31:22 -05:00
Jason DoveandGitHub 8cd9b23787 fix transcode folder preparation (#404) 2021-10-08 15:29:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dc5c9e42ff Bump Serilog.Settings.Configuration from 3.2.0 to 3.3.0 (#403)
Bumps [Serilog.Settings.Configuration](https://github.com/serilog/serilog-settings-configuration) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/serilog/serilog-settings-configuration/releases)
- [Changelog](https://github.com/serilog/serilog-settings-configuration/blob/dev/CHANGES.md)
- [Commits](https://github.com/serilog/serilog-settings-configuration/commits)

---
updated-dependencies:
- dependency-name: Serilog.Settings.Configuration
  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-10-08 09:09:57 -05:00
Jason DoveandGitHub 2dd267e4db fix xmltv generation with missing episode metadata (#402) 2021-10-07 22:31:46 -05:00
Jason DoveandGitHub b069a21473 allow hls segmenter to run before framerate is known (#401) 2021-10-07 22:07:54 -05:00
Jason DoveandGitHub 6c8813ce22 add hls segmenter streaming mode (#400)
* hls segmenter wip

* log message

* close unused transcode sessions after 2 minutes

* use frame rate for 2s keyframes in hls segmenter

* add frame rate to media version

* fix segmenter framerate calculation

* automatically restart hls segmenter with next scheduled item

* cleanup

* update changelog

* decrease segmenter start delay
2021-10-07 21:42:29 -05:00
Jason DoveandGitHub b5de5e2b7f fix statistic updates (#399) 2021-10-07 19:56:41 -05:00
Jason DoveandGitHub 4b7da4e468 speed up builds by using base images (#398) 2021-10-06 06:06:13 -05:00
Jason DoveandGitHub ae8e795228 vaapi downsample 10bit hevc 8bit h264 (#397)
* vaapi downsample 10bit hevc to 8bit h264

* update changelog
2021-10-05 22:16:57 -05:00
Jason DoveandGitHub 334781485d use latest intel driver in vaapi docker images (#396)
* compile latest iHD driver

* cleanup to reduce image size

* update changelog
2021-10-05 20:24:26 -05:00
Jason Dove 27fefa1b38 update changelog for release 62 [no ci] 2021-10-05 15:28:45 -05:00
Jason DoveandGitHub fc3175591e use libx264 for all errors (#395) 2021-10-05 15:13:35 -05:00
Jason DoveandGitHub 3363d2c9d7 update plex paths when they are changed (#394) 2021-10-05 06:27:05 -05:00
Jason DoveandGitHub 1d5217fa84 support imdb ids from plex (#393) 2021-10-05 06:01:53 -05:00
Jason DoveandGitHub 904cdb8780 vaapi improvements (#392)
* add transcoding tests

* dont use full paths for transcoding tests

* add error details

* use 1-second videos for transcoding tests

* vaapi fixes

* include format in scale_vaapi

* more vaapi fixes

* unsupported errors

* fix unsupported checks

* maybe not failure?

* fix formatting

* ignore nvdec warnings

* update changelog

* fix tests
2021-10-02 13:42:44 -05:00
Jason Dove 85fee64565 update changelog [no ci] 2021-10-01 13:44:04 -05:00
Jason DoveandGitHub 13cfb9728f include season zero episode-num in xmltv (#391) 2021-10-01 13:42:46 -05:00
Jason DoveandGitHub 60b82876ea mudblazor updates (#390) 2021-09-30 17:40:26 -05:00
Jason DoveandGitHub a99249c375 revert nvenc changes (#389) 2021-09-30 17:35:44 -05:00
Jason Dove 36e6ef4c18 update changelog for release 60 2021-09-25 15:12:47 -05:00
Jason DoveandGitHub 21e53532c1 trakt season bug fixes (#386) 2021-09-25 14:55:42 -05:00
Jason DoveandGitHub a864d53327 add seasons to search index (#385)
* update trakt list items when re-adding existing list

* add seasons to search index
2021-09-25 14:01:35 -05:00
Jason DoveandGitHub e6446f9983 better trakt lists (#384)
* better trakt list support

* update dependencies

* revert unneeded brackets
2021-09-25 09:12:25 -05:00
Jason DoveandGitHub ad40213f90 fix synchronizing trakt lists that contain unreleased movies (#382) 2021-09-21 21:14:27 -05:00
Jason DoveandGitHub 45c6d20fd0 sync trakt list to collection (#381)
* sync trakt list to collection

* move trakt client id
2021-09-20 18:46:03 -05:00
Jason DoveandGitHub 5439db89a7 nvidia fixes (#380)
* nvidia fixes

* fix tests
2021-09-19 21:39:36 -05:00
Jason DoveandGitHub a39231bb5a fix local episode metadata update (#379) 2021-09-19 20:57:12 -05:00
Jason Dove 4c8584b517 try to fix develop versioning 2021-09-18 18:08:19 -05:00
Jason Dove ca8bcacbd3 update changelog for release 59 [no ci] 2021-09-18 14:35:01 -05:00
Jason DoveandGitHub f27286d1dd properly disable transcoding when unchecked in mpeg-ts mode (#378)
* properly disable transcoding in MPEG-TS mode

* update changelog
2021-09-18 14:21:49 -05:00
Jason Dove 23870b75f7 update changelog [no ci] 2021-09-18 14:02:49 -05:00
Jason Dove 7f5a91c643 include libva-x11-2 in vaapi docker image 2021-09-18 13:25:01 -05:00
Jason DoveandGitHub f1f50e883c add vaapi driver setting and health check (#377)
* add vaapi driver option

* add vaapi driver setting and health check
2021-09-18 13:00:36 -05:00
Jason Dove 7506f49f5b remove codeql [no docker] 2021-09-18 11:30:16 -05:00
Jason DoveandGitHub 944f1e4307 add scheduled playout rebuild (#376)
* configure scheduled playout rebuild

* implement scheduled playout rebuild

* remove variable
2021-09-18 11:23:58 -05:00
Jason DoveandGitHub f7de9ac5ea include intel-media-va-driver-non-free in vaapi image (#375)
* include intel-media-va-driver-non-free in vaapi image

* tweak changelog
2021-09-17 21:13:27 -05:00
Jason DoveandGitHub 1eb51ad2f4 add some health checks to home page (#374) 2021-09-17 17:59:59 -05:00
Jason DoveandGitHub c3e0aaf0b7 missing metadata fixes (#373) 2021-09-17 08:53:41 -05:00
Jason Dove b9912b47df update changelog for release 58 [no ci] 2021-09-15 21:53:34 -05:00
Jason DoveandGitHub 55fb2624e7 add multi-part grouping tooltip (#371) 2021-09-15 21:18:41 -05:00
Jason DoveandGitHub 8ced20dc39 dont offset collections during shuffle in order (#370) 2021-09-15 20:54:32 -05:00
Jason DoveandGitHub e718cb0faf fix building playouts in timezones with positive offsets (#368) 2021-09-15 09:07:35 -05:00
Jason DoveandGitHub e218ff9a6d fix watermark when no video filters are required (#367) 2021-09-15 05:12:23 -05:00
Jason DoveandGitHub c2a49cbaea update dependencies (#365) 2021-09-14 18:10:59 -05:00
Jason DoveandGitHub 17e74f7314 add more release date search options (#362) 2021-09-12 18:32:38 -05:00
Long-ManandGitHub 2032bb4777 Update search.md (#361)
Add release_date and released_nointhelast to music video search
2021-09-12 12:18:07 -05:00
Jason Dove 7877ec641e update changelog for release 57 [no ci] 2021-09-11 10:37:23 -05:00
Jason DoveandGitHub 767a9779bb more kodi artwork fixes (#360) 2021-09-11 10:08:33 -05:00
Jason DoveandGitHub bb9127e546 fix artwork in kodi (#359) 2021-09-11 09:45:09 -05:00
Jason DoveandGitHub c932577cb8 allow adding smart collections to multi collections (#358) 2021-09-11 09:29:20 -05:00
Jason DoveandGitHub ad2685fb2e add released_inthelast queries (#357) 2021-09-10 21:11:14 -05:00
443 changed files with 118383 additions and 1342 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
tag=$(git describe --tags --abbrev=0) tag=$(git describe --tags --abbrev=0)
tag2="${tag:1}" tag2="${tag:1}"
short=$(git rev-parse --short HEAD) short=$(git rev-parse --short HEAD)
final="${tag2/prealpha/$short}" final="${tag2/alpha/$short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Set up Docker Buildx Base - name: Set up Docker Buildx Base
-71
View File
@@ -1,71 +0,0 @@
# 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
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
run: | run: |
tag=$(git describe --tags --abbrev=0) tag=$(git describe --tags --abbrev=0)
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag/-prealpha/}" >> $GITHUB_ENV echo "DOCKER_TAG=${tag/-alpha/}" >> $GITHUB_ENV
- name: Set up Docker Buildx Base - name: Set up Docker Buildx Base
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
+222 -1
View File
@@ -5,6 +5,213 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
## [0.2.2-alpha] - 2021-10-30
### Fixed
- Fix EPG entries for Duration schedule items that play multiple items
- Fix EPG entries for Multiple schedule items that play more than one item
### Added
- Add fallback filler settings to Channel and global FFmpeg Settings
- When streaming is attempted during an unscheduled gap, the resulting video will be determined using the following priority:
- Channel fallback filler
- Global fallback filler
- Generated `Channel Is Offline` error message video
### Changed
- Allow per-episode folders for local show libraries
- e.g. `Show Name\Season #\Episode #\Show Name - s#e#.mkv`
## [0.2.1-alpha] - 2021-10-24
### Fixed
- Fix saving dynamic start time on schedule items
## [0.2.0-alpha] - 2021-10-23
### Fixed
- Fix generated streams with mpeg2video
- Fix incorrect row count in playout detail table
- Fix deleting movies that have been removed from Jellyfin and Emby
- Fix bug that caused large unscheduled gaps in playouts
- This was caused by schedule items with a fixed start of midnight
### Added
- Add new filler system
- `Pre-Roll Filler` plays before each media item
- `Mid-Roll Filler` plays between media item chapters
- `Post-Roll Filler` plays after each media item
- `Tail Filler` plays after all media items, until the next media item
- `Fallback Filler` loops instead of default offline image to fill any remaining gaps
- Store chapter details with media statistics; this is needed to support mid-roll filler
- This requires re-ingesting statistics for all media items the first time this version is launched
- Add switch to show/hide filler in playout detail table
- Add `minutes` field to search index
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Changed
- Change some debug log messages to info so they show by default again
- Remove tail collection options from `Duration` playout mode
- Show localized start time in schedule items tables
## [0.1.5-alpha] - 2021-10-18
### Fixed
- Fix double scheduling; this could happen if the app was shutdown during a playout build
- Fix updating Jellyfin and Emby TV seasons
- Fix updating Jellyfin and Emby artwork
- Fix Plex, Jellyfin, Emby worker crash attempting to sync library that no longer exists
- Fix bug with `Duration` mode scheduling when media items are too long to fit in the requested duration
### Added
- Include music video thumbnails in channel guide (xmltv)
### Changed
- Automatically find working Plex address on startup
- Automatically select schedule item in schedules that contain only one item
- Change default log level from `Debug` to `Information`
- The `Debug` log level can be enabled in the `appsettings.json` file for non-docker installs
- The `Debug` log level can be enabled by setting the environment variable `Serilog:MinimumLevel=Debug` for docker installs
## [0.1.4-alpha] - 2021-10-14
### Fixed
- Fix error message/offline stream continuity with channels that use HLS Segmenter
- Fix removing items from search index when folders are removed from local libraries
### Added
- Add `Other Video` local libraries
- Other video items require no metadata or particular folder layout, and will have tags added for each containing folder
- For Example, a video at `commercials/sd/1990/whatever.mkv` will have the tags `commercials`, `sd` and `1990`, and the title `whatever`
- Add filler `Tail Mode` option to `Duration` playout mode (in addition to existing `Offline` option)
- Filler collection will always be randomized (to fill as much time as possible)
- Filler will be hidden from channel guide, but visible in playout details in ErsatzTV
- Unfilled time will show offline image
- Add `Guide Mode` option to all schedule items
- `Normal` guide mode will show all scheduled items in the channel guide (xmltv)
- `Filler` guide mode will hide all scheduled items from the channel guide, and extend the end time for the previous item in the guide
## [0.1.3-alpha] - 2021-10-13
### Fixed
- Fix startup bug for some docker installations
## [0.1.2-alpha] - 2021-10-12
### Added
- Include more cuda (nvidia) filters in docker image
- Enable deinterlacing with nvidia using new `yadif_cuda` filter
- Add two HLS Segmenter settings: idle timeout and work-ahead limit
- `HLS Segmenter Idle Timeout` - the number of seconds to keep transcoding a channel while no requests have been received from any client
- This setting must be greater than or equal to 30 (seconds)
- `Work-Ahead HLS Segmenter Limit` - the number of segmenters (channels) that will work-ahead simultaneously (if multiple channels are being watched)
- "working ahead" means transcoding at full speed, which can take a lot of resources
- This setting must be greater than or equal to 0
- Add more watermark locations ("middle" of each side)
- Add `VAAPI Device` setting to ffmpeg profile to support installations with multiple video cards
- Add *experimental* `RadeonSI` option for `VAAPI Driver` and include mesa drivers in vaapi docker image
### Changed
- Upgrade ffmpeg from 4.3 to 4.4 in all docker images
- Upgrading from 4.3 to 4.4 is recommended for all installations
- Move `VAAPI Driver` from settings page to ffmpeg profile to support installations with multiple video cards
### Fixed
- Fix some transcoding edge cases with nvidia and pixel format `yuv420p10le`
## [0.1.1-alpha] - 2021-10-10
### Added
- Add music video album to search index
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Changed
- Remove forced initial delay from `HLS Segmenter` streaming mode
- Upgrade nvidia docker image from 18.04 to 20.04
## [0.1.0-alpha] - 2021-10-08
### Added
- Add *experimental* streaming mode `HLS Segmenter` (most similar to `HLS Hybrid`)
- This mode is intended to increase client compatibility and reduce issues at program boundaries
- If you want the temporary transcode files to be located on a particular drive, the docker path is `/root/.local/share/etv-transcode`
- Store frame rate with media statistics; this is needed to support HLS Segmenter
- This requires re-ingesting statistics for all media items the first time this version is launched
### Changed
- Use latest iHD driver (21.2.3 vs 20.1.1) in vaapi docker images
### Fixed
- Add downsampling to support transcoding 10-bit HEVC content with the h264_vaapi encoder
- Fix updating statistics when media items are replaced
- Fix XMLTV generation when scheduled episode is missing metadata
## [0.0.62-alpha] - 2021-10-05
### Added
- Support IMDB ids from Plex libraries, which may improve Trakt matching for some items
### Fixed
- Include Specials/Season 0 `episode-num` entry in XMLTV
- Fix some transcoding edge cases with VAAPI and pixel formats `yuv420p10le`, `yuv444p10le` and `yuv444p`
- Update Plex movie and episode paths when they are changed within Plex
- Always use `libx264` software encoder for error messages
## [0.0.61-alpha] - 2021-09-30
### Fixed
- Revert nvenc/cuda filter change from v60
## [0.0.60-alpha] - 2021-09-25
### Added
- Add Trakt list support under `Lists` > `Trakt Lists`
- Trakt lists can be added by url or by `user/list`
- To re-download a Trakt list, simply add it again (no need to delete)
- See `Logs` for unmatched item details
- Trakt lists can only be scheduled by using Smart Collections
- Add seasons to search index
- This is needed because Trakt lists can contain seasons
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Fixed
- Fix local television scanner to properly update episode metadata when NFO files have been added/changed
- Properly detect ffmpeg nvenc (cuda) support in Hardware Acceleration health check
- Fix nvenc/cuda filter for some yuv420p content
## [0.0.59-alpha] - 2021-09-18
### Added
- Add `Health Checks` table to home page to identify and surface common misconfigurations
- `FFmpeg Version` checks `ffmpeg` and `ffprobe` versions
- `FFmpeg Reports` checks whether ffmpeg troubleshooting reports are enabled since they can use a lot of disk space over time
- `Hardware Acceleration` checks whether channels that transcode are using acceleration methods that ffmpeg claims to support
- `Movie Metadata` checks whether all movies have metadata (fallback metadata counts as metadata)
- `Episode Metadata` checks whether all episodes have metadata (fallback metadata counts as metadata)
- `Zero Duration` checks whether all movies and episodes have a valid (non-zero) duration
- `VAAPI Driver` checks whether a vaapi driver preference is configured when using the vaapi docker image
- Add setting to each playout to schedule an automatic daily rebuild
- This is useful if the playout uses a smart collection with `released_onthisday`
### Fixed
- Fix docker vaapi support for newer Intel platforms (Gen 8+)
- This includes a new setting to force a particular vaapi driver (`iHD` or `i965`), as some Gen 8 or 9 hardware that is supported by both drivers will perform better with one or the other
- Fix scanning and indexing local movies and episodes without NFO metadata
- Fix displaying seasons for shows with no year (in metadata or in folder name)
- Fix "direct play" in MPEG-TS mode (copy audio and video stream when `Transcode` is unchecked)
## [0.0.58-alpha] - 2021-09-15
### Added
- Add `released_notinthelast` search field for relative release date queries
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
- Add `released_onthisday` search field for historical queries
- Syntax is `released_onthisday:1` and will search for items released on this month number and day number in prior years
- Add tooltip explaining `Keep Multi-Part Episodes Together`
### Fixed
- Properly display watermark when no other video filters (like scaling or padding) are required
- Fix building some playouts in timezones with positive offsets (like UTC+2)
- Fix `Shuffle In Order` so all collections/shows start from the earliest episode
- You may need to rebuild playouts to see this fixed behavior more quickly
## [0.0.57-alpha] - 2021-09-10
### Added
- Add `released_inthelast` search field for relative release date queries
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
- Allow adding smart collections to multi collections
### Fixed
- Fix loading artwork in Kodi
- Use fake image extension (`.jpg`) for artwork in M3U and XMLTV since Kodi detects MIME type from URL
- Enable HEAD requests for IPTV image paths since Kodi requires those
## [0.0.56-alpha] - 2021-09-10 ## [0.0.56-alpha] - 2021-09-10
### Added ### Added
- Add Smart Collections - Add Smart Collections
@@ -569,7 +776,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker. - Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...HEAD [Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.1-alpha...HEAD
[0.2.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.0-alpha...v0.2.1-alpha
[0.2.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.5-alpha...v0.2.0-alpha
[0.1.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.4-alpha...v0.1.5-alpha
[0.1.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.3-alpha...v0.1.4-alpha
[0.1.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.2-alpha...v0.1.3-alpha
[0.1.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.1-alpha...v0.1.2-alpha
[0.1.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-alpha...v0.1.1-alpha
[0.1.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...v0.1.0-alpha
[0.0.62-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
[0.0.61-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
[0.0.60-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...v0.0.60-alpha
[0.0.59-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.58-alpha
[0.0.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
[0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha [0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha [0.0.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.54-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
@@ -10,5 +10,6 @@ namespace ErsatzTV.Application.Channels
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId); int? WatermarkId,
int? FallbackFillerId);
} }
@@ -13,5 +13,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId) : IRequest<Either<BaseError, CreateChannelResult>>; int? WatermarkId,
int? FallbackFillerId) : IRequest<Either<BaseError, CreateChannelResult>>;
} }
@@ -7,6 +7,7 @@ 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.Domain.Filler;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
@@ -42,9 +43,10 @@ namespace ErsatzTV.Application.Channels.Commands
(ValidateName(request), await ValidateNumber(dbContext, request), (ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request), await FFmpegProfileMustExist(dbContext, request),
ValidatePreferredLanguage(request), ValidatePreferredLanguage(request),
await WatermarkMustExist(dbContext, request)) await WatermarkMustExist(dbContext, request),
await FillerPresetMustExist(dbContext, request))
.Apply( .Apply(
(name, number, ffmpegProfileId, preferredLanguageCode, watermarkId) => (name, number, ffmpegProfileId, preferredLanguageCode, watermarkId, fillerPresetId) =>
{ {
var artwork = new List<Artwork>(); var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo)) if (!string.IsNullOrWhiteSpace(request.Logo))
@@ -74,6 +76,11 @@ namespace ErsatzTV.Application.Channels.Commands
channel.WatermarkId = id; channel.WatermarkId = id;
} }
foreach (int id in fillerPresetId)
{
channel.FallbackFillerId = id;
}
return channel; return channel;
}); });
@@ -131,5 +138,25 @@ namespace ErsatzTV.Application.Channels.Commands
.MapT(_ => Optional(createChannel.WatermarkId)) .MapT(_ => Optional(createChannel.WatermarkId))
.Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist.")); .Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist."));
} }
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
TvContext dbContext,
CreateChannel createChannel)
{
if (createChannel.FallbackFillerId is null)
{
return Option<int>.None;
}
return await dbContext.FillerPresets
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
.CountAsync(w => w.Id == createChannel.FallbackFillerId)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.FallbackFillerId))
.Map(
o => o.ToValidation<BaseError>(
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
}
} }
} }
@@ -14,5 +14,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId) : IRequest<Either<BaseError, ChannelViewModel>>; int? WatermarkId,
int? FallbackFillerId) : IRequest<Either<BaseError, ChannelViewModel>>;
} }
@@ -67,6 +67,7 @@ namespace ErsatzTV.Application.Channels.Commands
c.StreamingMode = update.StreamingMode; c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId; c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return ProjectToViewModel(c); return ProjectToViewModel(c);
} }
+2 -1
View File
@@ -15,7 +15,8 @@ namespace ErsatzTV.Application.Channels
GetLogo(channel), GetLogo(channel),
channel.PreferredLanguageCode, channel.PreferredLanguageCode,
channel.StreamingMode, channel.StreamingMode,
channel.WatermarkId); channel.WatermarkId,
channel.FallbackFillerId);
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))
@@ -28,6 +28,10 @@ namespace ErsatzTV.Application.Channels.Queries
{ {
switch (mode.ToLowerInvariant()) switch (mode.ToLowerInvariant())
{ {
case "segmenter":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "hls-direct": case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect; channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel); result.Add(channel);
@@ -25,7 +25,7 @@ namespace ErsatzTV.Application.Configuration.Commands
private static 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) .Where(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();
@@ -58,7 +58,7 @@ namespace ErsatzTV.Application.Configuration.Commands
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) => private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) =>
Optional(request.DaysToBuild) Optional(request.DaysToBuild)
.Filter(days => days > 0) .Where(days => days > 0)
.Map(_ => Unit.Default) .Map(_ => Unit.Default)
.ToValidation<BaseError>("Days to build must be greater than zero") .ToValidation<BaseError>("Days to build must be greater than zero")
.AsTask(); .AsTask();
@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Emby.Commands
{ {
EmbySecrets secrets = await _embySecretStore.ReadSecrets(); EmbySecrets secrets = await _embySecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Filter(match => match) .Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Emby media source requires an api key"); .ToValidation<BaseError>("Emby media source requires an api key");
} }
@@ -67,7 +67,7 @@ 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 ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now) if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{ {
@@ -142,7 +142,7 @@ namespace ErsatzTV.Application.Emby.Commands
{ {
EmbySecrets secrets = await _embySecretStore.ReadSecrets(); EmbySecrets secrets = await _embySecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Filter(match => match) .Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Emby media source requires an api key"); .ToValidation<BaseError>("Emby media source requires an api key");
} }
@@ -7,13 +7,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</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.10.56"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -1,5 +1,6 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
@@ -10,6 +11,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
int ThreadCount, int ThreadCount,
bool Transcode, bool Transcode,
HardwareAccelerationKind HardwareAcceleration, HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId, int ResolutionId,
bool NormalizeVideo, bool NormalizeVideo,
string VideoCodec, string VideoCodec,
@@ -45,6 +45,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
ThreadCount = threadCount, ThreadCount = threadCount,
Transcode = request.Transcode, Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration, HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
ResolutionId = resolutionId, ResolutionId = resolutionId,
NormalizeVideo = request.NormalizeVideo, NormalizeVideo = request.NormalizeVideo,
VideoCodec = request.VideoCodec, VideoCodec = request.VideoCodec,
@@ -1,5 +1,6 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
@@ -11,6 +12,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
int ThreadCount, int ThreadCount,
bool Transcode, bool Transcode,
HardwareAccelerationKind HardwareAcceleration, HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId, int ResolutionId,
bool NormalizeVideo, bool NormalizeVideo,
string VideoCodec, string VideoCodec,
@@ -36,18 +36,20 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.ThreadCount = update.ThreadCount; p.ThreadCount = update.ThreadCount;
p.Transcode = update.Transcode; p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration; p.HardwareAcceleration = update.HardwareAcceleration;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.ResolutionId = update.ResolutionId; p.ResolutionId = update.ResolutionId;
p.NormalizeVideo = update.NormalizeVideo; p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
p.VideoCodec = update.VideoCodec; p.VideoCodec = update.VideoCodec;
p.VideoBitrate = update.VideoBitrate; p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize; p.VideoBufferSize = update.VideoBufferSize;
p.AudioCodec = update.AudioCodec; p.AudioCodec = update.AudioCodec;
p.AudioBitrate = update.AudioBitrate; p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize; p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudness = update.NormalizeLoudness; p.NormalizeLoudness = update.Transcode && update.NormalizeLoudness;
p.AudioChannels = update.AudioChannels; p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate; p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.NormalizeAudio; p.NormalizeAudio = update.Transcode && update.NormalizeAudio;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return new UpdateFFmpegProfileResult(p.Id); return new UpdateFFmpegProfileResult(p.Id);
} }
@@ -115,6 +115,25 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId); await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
} }
if (request.Settings.GlobalFallbackFillerId is not null)
{
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegGlobalFallbackFillerId,
request.Settings.GlobalFallbackFillerId.Value);
}
else
{
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId);
}
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegSegmenterTimeout,
request.Settings.HlsSegmenterIdleTimeout);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegWorkAheadSegmenters,
request.Settings.WorkAheadSegmenterLimit);
return Unit.Default; return Unit.Default;
} }
} }
@@ -1,5 +1,6 @@
using ErsatzTV.Application.Resolutions; using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.FFmpegProfiles namespace ErsatzTV.Application.FFmpegProfiles
{ {
@@ -9,6 +10,8 @@ namespace ErsatzTV.Application.FFmpegProfiles
int ThreadCount, int ThreadCount,
bool Transcode, bool Transcode,
HardwareAccelerationKind HardwareAcceleration, HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
ResolutionViewModel Resolution, ResolutionViewModel Resolution,
bool NormalizeVideo, bool NormalizeVideo,
string VideoCodec, string VideoCodec,
@@ -8,5 +8,8 @@
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; } public int? GlobalWatermarkId { get; set; }
public int? GlobalFallbackFillerId { get; set; }
public int HlsSegmenterIdleTimeout { get; set; }
public int WorkAheadSegmenterLimit { get; set; }
} }
} }
@@ -12,6 +12,8 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.ThreadCount, profile.ThreadCount,
profile.Transcode, profile.Transcode,
profile.HardwareAcceleration, profile.HardwareAcceleration,
profile.VaapiDriver,
profile.VaapiDevice,
Project(profile.Resolution), Project(profile.Resolution),
profile.NormalizeVideo, profile.NormalizeVideo,
profile.VideoCodec, profile.VideoCodec,
@@ -28,6 +28,12 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode); await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<int> watermark = Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId); await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
Option<int> fallbackFiller =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId);
Option<int> hlsSegmenterIdleTimeout =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout);
Option<int> workAheadSegmenterLimit =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
var result = new FFmpegSettingsViewModel var result = new FFmpegSettingsViewModel
{ {
@@ -36,6 +42,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
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"),
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
}; };
foreach (int watermarkId in watermark) foreach (int watermarkId in watermark)
@@ -43,6 +51,11 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
result.GlobalWatermarkId = watermarkId; result.GlobalWatermarkId = watermarkId;
} }
foreach (int fallbackFillerId in fallbackFiller)
{
result.GlobalFallbackFillerId = fallbackFillerId;
}
return result; return result;
} }
} }
@@ -0,0 +1,24 @@
using System;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Filler.Commands
{
public record CreateFillerPreset(
string Name,
FillerKind FillerKind,
FillerMode FillerMode,
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId
) : IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,55 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Filler.Commands
{
public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, Unit>> Handle(CreateFillerPreset request, CancellationToken cancellationToken)
{
try
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
var fillerPreset = new FillerPreset
{
Name = request.Name,
FillerKind = request.FillerKind,
FillerMode = request.FillerMode,
Duration = request.Duration,
Count = request.Count,
PadToNearestMinute = request.PadToNearestMinute,
CollectionType = request.CollectionType,
CollectionId = request.CollectionId,
MediaItemId = request.MediaItemId,
MultiCollectionId = request.MultiCollectionId,
SmartCollectionId = request.SmartCollectionId
};
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Filler.Commands
{
public record DeleteFillerPreset(int FillerPresetId) : IRequest<Either<BaseError, LanguageExt.Unit>>;
}
@@ -0,0 +1,43 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Filler.Commands
{
public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Unit>> Handle(
DeleteFillerPreset request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
return await validation.Apply(ps => DoDeletion(dbContext, ps));
}
private static Task<Unit> DoDeletion(TvContext dbContext, FillerPreset fillerPreset)
{
dbContext.FillerPresets.Remove(fillerPreset);
return dbContext.SaveChangesAsync().ToUnit();
}
private Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
TvContext dbContext,
DeleteFillerPreset request) =>
dbContext.FillerPresets
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId)
.Map(o => o.ToValidation<BaseError>($"FillerPreset {request.FillerPresetId} does not exist."));
}
}
@@ -0,0 +1,25 @@
using System;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Filler.Commands
{
public record UpdateFillerPreset(
int Id,
string Name,
FillerKind FillerKind,
FillerMode FillerMode,
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId
) : IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,60 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Filler.Commands
{
public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
}
private async Task<Unit> ApplyUpdateRequest(
TvContext dbContext,
FillerPreset existing,
UpdateFillerPreset request)
{
existing.Name = request.Name;
existing.FillerKind = request.FillerKind;
existing.FillerMode = request.FillerMode;
existing.Duration = request.Duration;
existing.Count = request.Count;
existing.PadToNearestMinute = request.PadToNearestMinute;
existing.CollectionType = request.CollectionType;
existing.CollectionId = request.CollectionId;
existing.MediaItemId = request.MediaItemId;
existing.MultiCollectionId = request.MultiCollectionId;
existing.SmartCollectionId = request.SmartCollectionId;
await dbContext.SaveChangesAsync();
return Unit.Default;
}
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
TvContext dbContext,
UpdateFillerPreset request) =>
dbContext.FillerPresets
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id)
.Map(o => o.ToValidation<BaseError>("FillerPreset does not exist"));
}
}
@@ -0,0 +1,20 @@
using System;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Filler
{
public record FillerPresetViewModel(
int Id,
string Name,
FillerKind FillerKind,
FillerMode FillerMode,
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId);
}
+22
View File
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Filler
{
internal static class Mapper
{
internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) =>
new(
fillerPreset.Id,
fillerPreset.Name,
fillerPreset.FillerKind,
fillerPreset.FillerMode,
fillerPreset.Duration,
fillerPreset.Count,
fillerPreset.PadToNearestMinute,
fillerPreset.CollectionType,
fillerPreset.CollectionId,
fillerPreset.MediaItemId,
fillerPreset.MultiCollectionId,
fillerPreset.SmartCollectionId);
}
}
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Filler
{
public record PagedFillerPresetsViewModel(int TotalCount, List<FillerPresetViewModel> Page);
}
@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Filler.Queries
{
public record GetAllFillerPresets : IRequest<List<FillerPresetViewModel>>;
}
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using MediatR;
using Microsoft.EntityFrameworkCore;
using LanguageExt;
using static ErsatzTV.Application.Filler.Mapper;
namespace ErsatzTV.Application.Filler.Queries
{
public class GetAllFillerPresetsHandler : IRequestHandler<GetAllFillerPresets, List<FillerPresetViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllFillerPresetsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<FillerPresetViewModel>> Handle(
GetAllFillerPresets request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.FillerPresets.ToListAsync(cancellationToken)
.Map(presets => presets.Map(ProjectToViewModel).ToList());
}
}
}
@@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Filler.Queries
{
public record GetFillerPresetById(int Id) : IRequest<Option<FillerPresetViewModel>>;
}
@@ -0,0 +1,29 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Filler.Mapper;
namespace ErsatzTV.Application.Filler.Queries
{
public class GetFillerPresetByIdHandler : IRequestHandler<GetFillerPresetById, Option<FillerPresetViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetFillerPresetByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<FillerPresetViewModel>> Handle(
GetFillerPresetById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.FillerPresets
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.MapT(ProjectToViewModel);
}
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Filler.Queries
{
public record GetPagedFillerPresets(int PageNum, int PageSize) : IRequest<PagedFillerPresetsViewModel>;
}
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Infrastructure.Data;
using MediatR;
using Microsoft.EntityFrameworkCore;
using LanguageExt;
using static ErsatzTV.Application.Filler.Mapper;
namespace ErsatzTV.Application.Filler.Queries
{
public class GetPagedFillerPresetsHandler : IRequestHandler<GetPagedFillerPresets, PagedFillerPresetsViewModel>
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPagedFillerPresetsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<PagedFillerPresetsViewModel> Handle(
GetPagedFillerPresets request,
CancellationToken cancellationToken)
{
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM FillerPreset");
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<FillerPresetViewModel> page = await dbContext.FillerPresets.FromSqlRaw(
@"SELECT * FROM FillerPreset
ORDER BY Name
COLLATE NOCASE
LIMIT {0} OFFSET {1}",
request.PageSize,
request.PageNum * request.PageSize)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedFillerPresetsViewModel(count, page);
}
}
}
@@ -24,7 +24,7 @@ namespace ErsatzTV.Application.HDHR.Commands
private static 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) .Where(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();
@@ -0,0 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Core.Health;
using MediatR;
namespace ErsatzTV.Application.Health.Queries
{
public record GetAllHealthCheckResults : IRequest<List<HealthCheckResult>>;
}
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Health;
using MediatR;
namespace ErsatzTV.Application.Health.Queries
{
public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheckResults, List<HealthCheckResult>>
{
private readonly IHealthCheckService _healthCheckService;
public GetAllHealthCheckResultsHandler(IHealthCheckService healthCheckService) =>
_healthCheckService = healthCheckService;
public async Task<List<HealthCheckResult>> Handle(
GetAllHealthCheckResults request,
CancellationToken cancellationToken)
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks();
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
}
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IFFmpegWorkerRequest
{
}
}
@@ -97,7 +97,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
{ {
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets(); JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Filter(match => match) .Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Jellyfin media source requires an api key"); .ToValidation<BaseError>("Jellyfin media source requires an api key");
} }
@@ -69,7 +69,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
{ {
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets(); JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Filter(match => match) .Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Jellyfin media source requires an api key"); .ToValidation<BaseError>("Jellyfin media source requires an api key");
} }
@@ -67,7 +67,7 @@ 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 ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now) if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{ {
@@ -142,7 +142,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
{ {
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets(); JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Filter(match => match) .Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Jellyfin media source requires an api key"); .ToValidation<BaseError>("Jellyfin media source requires an api key");
} }
@@ -46,7 +46,7 @@ namespace ErsatzTV.Application.Libraries.Commands
.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))) .Where(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");
} }
@@ -33,7 +33,7 @@ namespace ErsatzTV.Application.Libraries.Commands
.Map(list => list.SelectMany(ll => ll.Paths).Map(lp => lp.Path).ToList()); .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)))) return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path))))
.Filter(length => length == 0) .Where(length => length == 0)
.Map(_ => localLibrary) .Map(_ => localLibrary)
.ToValidation<BaseError>("Path must not belong to another library path"); .ToValidation<BaseError>("Path must not belong to another library path");
} }
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@@ -8,6 +9,7 @@ using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
@@ -22,15 +24,18 @@ namespace ErsatzTV.Application.Libraries.Commands
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IEntityLocker _entityLocker; private readonly IEntityLocker _entityLocker;
private readonly ISearchIndex _searchIndex;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateLocalLibraryHandler( public UpdateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
IEntityLocker entityLocker, IEntityLocker entityLocker,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory) IDbContextFactory<TvContext> dbContextFactory)
{ {
_workerChannel = workerChannel; _workerChannel = workerChannel;
_entityLocker = entityLocker; _entityLocker = entityLocker;
_searchIndex = searchIndex;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
} }
@@ -56,10 +61,21 @@ namespace ErsatzTV.Application.Libraries.Commands
.Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path))) .Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path)))
.ToList(); .ToList();
var toRemoveIds = toRemove.Map(lp => lp.Id).ToList();
List<int> itemsToRemove = await dbContext.MediaItems
.Filter(mi => toRemoveIds.Contains(mi.LibraryPathId))
.Map(mi => mi.Id)
.ToListAsync();
existing.Paths.RemoveAll(toRemove.Contains); existing.Paths.RemoveAll(toRemove.Contains);
existing.Paths.AddRange(toAdd); existing.Paths.AddRange(toAdd);
await dbContext.SaveChangesAsync(); if (await dbContext.SaveChangesAsync() > 0)
{
await _searchIndex.RemoveItems(itemsToRemove);
_searchIndex.Commit();
}
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id)) if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id))
{ {
@@ -9,7 +9,8 @@ namespace ErsatzTV.Application.MediaCards
List<TelevisionSeasonCardViewModel> SeasonCards, List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards, List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards, List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards) List<MusicVideoCardViewModel> MusicVideoCards,
List<OtherVideoCardViewModel> OtherVideoCards)
{ {
public bool UseCustomPlaybackOrder { get; set; } public bool UseCustomPlaybackOrder { get; set; }
} }
+33 -3
View File
@@ -1,5 +1,5 @@
using System; using System.Linq;
using System.Linq; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
@@ -36,6 +36,26 @@ namespace ErsatzTV.Application.MediaCards
.IfNone(string.Empty), .IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString()); season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
SeasonMetadata seasonMetadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
string showTitle = seasonMetadata.Season.Show.ShowMetadata.HeadOrNone().Match(
m => m.Title ?? string.Empty,
() => string.Empty);
return new TelevisionSeasonCardViewModel(
showTitle,
seasonMetadata.SeasonId,
seasonMetadata.Season.SeasonNumber,
showTitle,
GetSeasonName(seasonMetadata.Season.SeasonNumber),
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString());
}
internal static TelevisionEpisodeCardViewModel ProjectToViewModel( internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
EpisodeMetadata episodeMetadata, EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin, Option<JellyfinMediaSource> maybeJellyfin,
@@ -43,7 +63,7 @@ namespace ErsatzTV.Application.MediaCards
bool isSearchResult) => bool isSearchResult) =>
new( new(
episodeMetadata.EpisodeId, episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue, episodeMetadata.ReleaseDate ?? SystemTime.MinValueUtc,
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match( episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
m => m.Title ?? string.Empty, m => m.Title ?? string.Empty,
() => string.Empty), () => string.Empty),
@@ -80,8 +100,16 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title, musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle, musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot, musicVideoMetadata.Plot,
musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None)); GetThumbnail(musicVideoMetadata, None, None));
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
new(
otherVideoMetadata.OtherVideoId,
otherVideoMetadata.Title,
otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle);
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) => internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new( new(
artistMetadata.ArtistId, artistMetadata.ArtistId,
@@ -112,6 +140,8 @@ namespace ErsatzTV.Application.MediaCards
.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()))
.ToList(),
collection.MediaItems.OfType<OtherVideo>().Map(mv => ProjectToViewModel(mv.OtherVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder }; .ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel( internal static ActorCardViewModel ProjectToViewModel(
@@ -7,6 +7,7 @@
string Subtitle, string Subtitle,
string SortTitle, string SortTitle,
string Plot, string Plot,
string Album,
string Poster) : MediaCardViewModel( string Poster) : MediaCardViewModel(
MusicVideoId, MusicVideoId,
Title, Title,
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record OtherVideoCardResultsViewModel(
int Count,
List<OtherVideoCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,17 @@
namespace ErsatzTV.Application.MediaCards
{
public record OtherVideoCardViewModel
(
int OtherVideoId,
string Title,
string Subtitle,
string SortTitle) : MediaCardViewModel(
OtherVideoId,
Title,
Subtitle,
SortTitle,
null)
{
public int CustomIndex { get; set; }
}
}
@@ -80,6 +80,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season) .ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.SeasonMetadata) .ThenInclude(s => s.SeasonMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Artwork)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id) .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));
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper; using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries namespace ErsatzTV.Application.MediaCards.Queries
{ {
@@ -41,7 +42,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize) .GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList()); .Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
return new TelevisionSeasonCardResultsViewModel(count, results); return new TelevisionSeasonCardResultsViewModel(count, results, 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 TelevisionSeasonCardResultsViewModel(int Count, List<TelevisionSeasonCardViewModel> Cards); public record TelevisionSeasonCardResultsViewModel(
int Count,
List<TelevisionSeasonCardViewModel> Cards,
Option<SearchPageMap> PageMap);
} }
@@ -9,7 +9,9 @@ namespace ErsatzTV.Application.MediaCollections.Commands
int CollectionId, int CollectionId,
List<int> MovieIds, List<int> MovieIds,
List<int> ShowIds, List<int> ShowIds,
List<int> SeasonIds,
List<int> EpisodeIds, List<int> EpisodeIds,
List<int> ArtistIds, List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>; List<int> MusicVideoIds,
List<int> OtherVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
} }
@@ -52,9 +52,11 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{ {
var allItems = request.MovieIds var allItems = request.MovieIds
.Append(request.ShowIds) .Append(request.ShowIds)
.Append(request.SeasonIds)
.Append(request.EpisodeIds) .Append(request.EpisodeIds)
.Append(request.ArtistIds) .Append(request.ArtistIds)
.Append(request.MusicVideoIds) .Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.ToList(); .ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList(); var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
@@ -77,12 +79,15 @@ namespace ErsatzTV.Application.MediaCollections.Commands
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, Collection>> Validate(TvContext dbContext, AddItemsToCollection request) => private async Task<Validation<BaseError, Collection>> Validate(
TvContext dbContext,
AddItemsToCollection request) =>
(await CollectionMustExist(dbContext, request), (await CollectionMustExist(dbContext, request),
await ValidateMovies(request), await ValidateMovies(request),
await ValidateShows(request), await ValidateShows(request),
await ValidateSeasons(request),
await ValidateEpisodes(request)) await ValidateEpisodes(request))
.Apply((collection, _, _, _) => collection); .Apply((collection, _, _, _, _) => collection);
private static Task<Validation<BaseError, Collection>> CollectionMustExist( private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext, TvContext dbContext,
@@ -106,6 +111,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.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>> ValidateSeasons(AddItemsToCollection request) =>
_televisionRepository.AllSeasonsExist(request.SeasonIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Season does not exist"));
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) => private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) =>
_televisionRepository.AllEpisodesExist(request.EpisodeIds) _televisionRepository.AllEpisodesExist(request.EpisodeIds)
.Map(Optional) .Map(Optional)
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddOtherVideoToCollection
(int CollectionId, int OtherVideoId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,80 @@
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 ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddOtherVideoToCollectionHandler :
MediatR.IRequestHandler<AddOtherVideoToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public AddOtherVideoToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
AddOtherVideoToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddOtherVideoRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.OtherVideo);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
AddOtherVideoToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateOtherVideo(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext,
AddOtherVideoToCollection 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, OtherVideo>> ValidateOtherVideo(
TvContext dbContext,
AddOtherVideoToCollection request) =>
dbContext.OtherVideos
.SelectOneAsync(m => m.Id, e => e.Id == request.OtherVideoId)
.Map(o => o.ToValidation<BaseError>("OtherVideo does not exist"));
private record Parameters(Collection Collection, OtherVideo OtherVideo);
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddTraktList(string TraktListUrl) : IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
}
@@ -0,0 +1,80 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktList, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
public AddTraktListHandler(
ITraktApiClient traktApiClient,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<AddTraktListHandler> logger,
IEntityLocker entityLocker)
: base(traktApiClient, searchRepository, searchIndex, logger)
{
_dbContextFactory = dbContextFactory;
_entityLocker = entityLocker;
}
public async Task<Either<BaseError, Unit>> Handle(AddTraktList request, CancellationToken cancellationToken)
{
try
{
Validation<BaseError, Parameters> validation = ValidateUrl(request);
return await validation.Match(
DoAdd,
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
}
finally
{
_entityLocker.UnlockTrakt();
}
}
private static Validation<BaseError, Parameters> ValidateUrl(AddTraktList request)
{
const string PATTERN = @"(?:https:\/\/trakt\.tv\/users\/)?([\w\-_]+)\/(?:lists\/)?([\w\-_]+)";
Match match = Regex.Match(request.TraktListUrl, PATTERN);
if (match.Success)
{
string user = match.Groups[1].Value;
string list = match.Groups[2].Value;
return new Parameters(user, list);
}
return BaseError.New("Invalid Trakt list url");
}
private async Task<Either<BaseError, Unit>> DoAdd(Parameters parameters)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await TraktApiClient.GetUserList(parameters.User, parameters.List)
.BindT(list => SaveList(dbContext, list))
.BindT(list => SaveListItems(dbContext, list))
.BindT(list => MatchListItems(dbContext, list))
.MapT(_ => Unit.Default);
// match list items (and update in search index)
}
private record Parameters(string User, string List);
}
}
@@ -60,7 +60,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name)); .Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createCollection.Name) var result2 = Optional(createCollection.Name)
.Filter(name => !allNames.Contains(name)) .Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("Collection name must be unique"); .ToValidation<BaseError>("Collection name must be unique");
return (result1, result2).Apply((_, _) => createCollection.Name); return (result1, result2).Apply((_, _) => createCollection.Name);
@@ -6,7 +6,7 @@ using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public record CreateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder); public record CreateMultiCollectionItem(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollection public record CreateMultiCollection
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>; (string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;
@@ -41,6 +41,11 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Query() .Query()
.Include(i => i.Collection) .Include(i => i.Collection)
.LoadAsync(); .LoadAsync();
await dbContext.Entry(multiCollection)
.Collection(c => c.MultiCollectionSmartItems)
.Query()
.Include(i => i.SmartCollection)
.LoadAsync();
return ProjectToViewModel(multiCollection); return ProjectToViewModel(multiCollection);
} }
@@ -51,12 +56,40 @@ namespace ErsatzTV.Application.MediaCollections.Commands
name => new MultiCollection name => new MultiCollection
{ {
Name = name, Name = name,
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem MultiCollectionItems = request.Items.Bind(
{ i =>
CollectionId = i.CollectionId, {
ScheduleAsGroup = i.ScheduleAsGroup, if (i.CollectionId.HasValue)
PlaybackOrder = i.PlaybackOrder {
}).ToList() return Some(
new MultiCollectionItem
{
CollectionId = i.CollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
return Option<MultiCollectionItem>.None;
})
.ToList(),
MultiCollectionSmartItems = request.Items.Bind(
i =>
{
if (i.SmartCollectionId.HasValue)
{
return Some(
new MultiCollectionSmartItem
{
SmartCollectionId = i.SmartCollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
return Option<MultiCollectionSmartItem>.None;
})
.ToList()
}); });
private static async Task<Validation<BaseError, string>> ValidateName( private static async Task<Validation<BaseError, string>> ValidateName(
@@ -71,7 +104,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name)); .Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createMultiCollection.Name) var result2 = Optional(createMultiCollection.Name)
.Filter(name => !allNames.Contains(name)) .Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique"); .ToValidation<BaseError>("MultiCollection name must be unique");
return (result1, result2).Apply((_, _) => createMultiCollection.Name); return (result1, result2).Apply((_, _) => createMultiCollection.Name);
@@ -61,7 +61,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name)); .Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createSmartCollection.Name) var result2 = Optional(createSmartCollection.Name)
.Filter(name => !allNames.Contains(name)) .Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("SmartCollection name must be unique"); .ToValidation<BaseError>("SmartCollection name must be unique");
return (result1, result2).Apply((_, _) => createSmartCollection.Name); return (result1, result2).Apply((_, _) => createSmartCollection.Name);
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record DeleteTraktList(int TraktListId) : IRequest<Either<BaseError, LanguageExt.Unit>>,
IBackgroundServiceRequest;
}
@@ -0,0 +1,79 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class DeleteTraktListHandler : TraktCommandBase, MediatR.IRequestHandler<DeleteTraktList, Either<BaseError, Unit>>
{
private readonly ISearchRepository _searchRepository;
private readonly ISearchIndex _searchIndex;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
public DeleteTraktListHandler(
ITraktApiClient traktApiClient,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<DeleteTraktListHandler> logger,
IEntityLocker entityLocker)
: base(traktApiClient, searchRepository, searchIndex, logger)
{
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_dbContextFactory = dbContextFactory;
_entityLocker = entityLocker;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteTraktList request,
CancellationToken cancellationToken)
{
try
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
finally
{
_entityLocker.UnlockTrakt();
}
}
private async Task<Unit> DoDeletion(TvContext dbContext, TraktList traktList)
{
var mediaItemIds = traktList.Items.Bind(i => Optional(i.MediaItemId)).ToList();
dbContext.TraktLists.Remove(traktList);
if (await dbContext.SaveChangesAsync() > 0)
{
foreach (int mediaItemId in mediaItemIds)
{
foreach (MediaItem mediaItem in await _searchRepository.GetItemToIndex(mediaItemId))
{
await _searchIndex.UpdateItems(_searchRepository, new[] { mediaItem }.ToList());
}
}
}
_searchIndex.Commit();
return Unit.Default;
}
}
}
@@ -0,0 +1,10 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record MatchTraktListItems(int TraktListId, bool Unlock = true) : IRequest<Either<BaseError, Unit>>,
IBackgroundServiceRequest;
}
@@ -0,0 +1,58 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class MatchTraktListItemsHandler : TraktCommandBase,
IRequestHandler<MatchTraktListItems, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
public MatchTraktListItemsHandler(
ITraktApiClient traktApiClient,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<MatchTraktListItemsHandler> logger,
IEntityLocker entityLocker) : base(traktApiClient, searchRepository, searchIndex, logger)
{
_dbContextFactory = dbContextFactory;
_entityLocker = entityLocker;
}
public async Task<Either<BaseError, Unit>> Handle(
MatchTraktListItems request,
CancellationToken cancellationToken)
{
try
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
return await validation.Match(
async l => await MatchListItems(dbContext, l).MapT(_ => Unit.Default),
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
}
finally
{
if (request.Unlock)
{
_entityLocker.UnlockTrakt();
}
}
}
}
}
@@ -0,0 +1,341 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Core.Trakt;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public abstract class TraktCommandBase
{
private readonly ISearchRepository _searchRepository;
private readonly ISearchIndex _searchIndex;
private readonly ILogger _logger;
protected TraktCommandBase(
ITraktApiClient traktApiClient,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
ILogger logger)
{
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_logger = logger;
TraktApiClient = traktApiClient;
}
protected ITraktApiClient TraktApiClient { get; }
protected static Task<Validation<BaseError, TraktList>>
TraktListMustExist(TvContext dbContext, int traktListId) =>
dbContext.TraktLists
.Include(l => l.Items)
.ThenInclude(i => i.Guids)
.SelectOneAsync(c => c.Id, c => c.Id == traktListId)
.Map(o => o.ToValidation<BaseError>($"TraktList {traktListId} does not exist."));
protected async Task<Either<BaseError, TraktList>> SaveList(TvContext dbContext, TraktList list)
{
Option<TraktList> maybeExisting = await dbContext.TraktLists
.Include(l => l.Items)
.ThenInclude(i => i.Guids)
.SelectOneAsync(tl => tl.Id, tl => tl.User == list.User && tl.List == list.List);
return await maybeExisting.Match(
async existing =>
{
existing.Name = list.Name;
existing.Description = list.Description;
existing.ItemCount = list.ItemCount;
await dbContext.SaveChangesAsync();
return existing;
},
async () =>
{
await dbContext.TraktLists.AddAsync(list);
await dbContext.SaveChangesAsync();
return list;
});
}
protected async Task<Either<BaseError, TraktList>> SaveListItems(TvContext dbContext, TraktList list)
{
Either<BaseError, List<TraktListItemWithGuids>> maybeItems =
await TraktApiClient.GetUserListItems(list.User, list.List);
return await maybeItems.Match<Task<Either<BaseError, TraktList>>>(
async items =>
{
var toAdd = items.Filter(i => list.Items.All(i2 => i2.TraktId != i.TraktId)).ToList();
var toRemove = list.Items.Filter(i => items.All(i2 => i2.TraktId != i.TraktId)).ToList();
var toUpdate = list.Items.Filter(i => !toRemove.Contains(i)).ToList();
list.Items.RemoveAll(toRemove.Contains);
list.Items.AddRange(toAdd.Map(a => ProjectItem(list, a)));
foreach (TraktListItem existing in toUpdate)
{
Option<TraktListItem> maybeIncoming = list.Items.Find(i => i.TraktId == existing.TraktId);
foreach (TraktListItem incoming in maybeIncoming)
{
existing.Kind = incoming.Kind;
existing.Rank = incoming.Rank;
existing.Title = incoming.Title;
existing.Year = incoming.Year;
existing.Season = incoming.Season;
existing.Episode = incoming.Episode;
existing.Guids.Clear();
existing.Guids.AddRange(incoming.Guids);
existing.MediaItemId = null;
existing.MediaItem = null;
}
}
await dbContext.SaveChangesAsync();
return list;
},
error => Task.FromResult(Left<BaseError, TraktList>(error)));
}
protected async Task<Either<BaseError, TraktList>> MatchListItems(TvContext dbContext, TraktList list)
{
try
{
var ids = new System.Collections.Generic.HashSet<int>();
foreach (TraktListItem item in list.Items
.OrderBy(i => i.Title).ThenBy(i => i.Year).ThenBy(i => i.Season).ThenBy(i => i.Episode))
{
switch (item.Kind)
{
case TraktListItemKind.Movie:
Option<int> maybeMovieId = await IdentifyMovie(dbContext, item);
foreach (int movieId in maybeMovieId)
{
ids.Add(movieId);
item.MediaItemId = movieId;
}
break;
case TraktListItemKind.Show:
Option<int> maybeShowId = await IdentifyShow(dbContext, item);
foreach (int showId in maybeShowId)
{
ids.Add(showId);
item.MediaItemId = showId;
}
break;
case TraktListItemKind.Season:
Option<int> maybeSeasonId = await IdentifySeason(dbContext, item);
foreach (int seasonId in maybeSeasonId)
{
ids.Add(seasonId);
item.MediaItemId = seasonId;
}
break;
default:
Option<int> maybeEpisodeId = await IdentifyEpisode(dbContext, item);
foreach (int episodeId in maybeEpisodeId)
{
ids.Add(episodeId);
item.MediaItemId = episodeId;
}
break;
}
}
await dbContext.SaveChangesAsync();
foreach (int mediaItemId in ids)
{
Option<MediaItem> maybeItem = await _searchRepository.GetItemToIndex(mediaItemId);
foreach (MediaItem item in maybeItem)
{
await _searchIndex.UpdateItems(_searchRepository, new[] { item }.ToList());
}
}
_searchIndex.Commit();
return list;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error matching trakt list items");
return BaseError.New(ex.Message);
}
}
private static TraktListItem ProjectItem(TraktList list, TraktListItemWithGuids item)
{
var result = new TraktListItem
{
TraktList = list,
Kind = item.Kind,
TraktId = item.TraktId,
Rank = item.Rank,
Title = item.Title,
Year = item.Year,
Season = item.Season,
Episode = item.Episode,
};
result.Guids = item.Guids.Map(g => new TraktListItemGuid { Guid = g, TraktListItem = result }).ToList();
return result;
}
private async Task<Option<int>> IdentifyMovie(TvContext dbContext, TraktListItem item)
{
var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeMovieByGuid = await dbContext.MovieMetadata
.Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(mm => mm.MovieId);
foreach (int movieId in maybeMovieByGuid)
{
_logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle);
return movieId;
}
Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata
.Filter(mm => mm.Title == item.Title && mm.Year == item.Year)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(mm => mm.MovieId);
foreach (int movieId in maybeMovieByTitleYear)
{
_logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle);
return movieId;
}
_logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle);
return None;
}
private async Task<Option<int>> IdentifyShow(TvContext dbContext, TraktListItem item)
{
var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeShowByGuid = await dbContext.ShowMetadata
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.ShowId);
foreach (int showId in maybeShowByGuid)
{
_logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle);
return showId;
}
Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata
.Filter(sm => sm.Title == item.Title && sm.Year == item.Year)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.ShowId);
foreach (int showId in maybeShowByTitleYear)
{
_logger.LogDebug("Located trakt show {Title} by title/year", item.Title);
return showId;
}
_logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle);
return None;
}
private async Task<Option<int>> IdentifySeason(TvContext dbContext, TraktListItem item)
{
var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.SeasonId);
foreach (int seasonId in maybeSeasonByGuid)
{
_logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle);
return seasonId;
}
Option<int> maybeSeasonByTitleYear = await dbContext.SeasonMetadata
.Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
.Filter(sm => sm.Season.SeasonNumber == item.Season)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.SeasonId);
foreach (int seasonId in maybeSeasonByTitleYear)
{
_logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle);
return seasonId;
}
_logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle);
return None;
}
private async Task<Option<int>> IdentifyEpisode(TvContext dbContext, TraktListItem item)
{
var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata
.Filter(em => em.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.EpisodeId);
foreach (int episodeId in maybeEpisodeByGuid)
{
_logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle);
return episodeId;
}
Option<int> maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata
.Filter(sm => sm.Episode.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
.Filter(em => em.Episode.Season.SeasonNumber == item.Season)
.Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode))
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.EpisodeId);
foreach (int episodeId in maybeEpisodeByTitleYear)
{
_logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle);
return episodeId;
}
_logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle);
return None;
}
}
}
@@ -5,7 +5,7 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public record UpdateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder); public record UpdateMultiCollectionItem(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record UpdateMultiCollection public record UpdateMultiCollection
( (
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Channels; using System.Threading.Channels;
@@ -48,11 +49,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
var toAdd = request.Items var toAdd = request.Items
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId)) .Filter(i => i.CollectionId.HasValue)
.Map( // ReSharper disable once PossibleInvalidOperationException
i => new MultiCollectionItem .Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId.Value))
.Map(i => new MultiCollectionItem
{ {
CollectionId = i.CollectionId, // ReSharper disable once PossibleInvalidOperationException
CollectionId = i.CollectionId.Value,
MultiCollectionId = c.Id, MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup, ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder PlaybackOrder = i.PlaybackOrder
@@ -79,6 +82,40 @@ namespace ErsatzTV.Application.MediaCollections.Commands
// add new items // add new items
c.MultiCollectionItems.AddRange(toAdd); c.MultiCollectionItems.AddRange(toAdd);
var toAddSmart = request.Items
.Filter(i => i.SmartCollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionSmartItems.All(i2 => i2.SmartCollectionId != i.SmartCollectionId.Value))
.Map(i => new MultiCollectionSmartItem
{
// ReSharper disable once PossibleInvalidOperationException
SmartCollectionId = i.SmartCollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
})
.ToList();
var toRemoveSmart = c.MultiCollectionSmartItems
.Filter(i => request.Items.All(i2 => i2.SmartCollectionId != i.SmartCollectionId))
.ToList();
// remove items that are no longer present
c.MultiCollectionSmartItems.RemoveAll(toRemoveSmart.Contains);
// update existing items
foreach (MultiCollectionSmartItem item in c.MultiCollectionSmartItems)
{
foreach (UpdateMultiCollectionItem incoming in request.Items.Filter(
i => i.SmartCollectionId == item.SmartCollectionId))
{
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
item.PlaybackOrder = incoming.PlaybackOrder;
}
}
// add new items
c.MultiCollectionSmartItems.AddRange(toAddSmart);
// rebuild playouts // rebuild playouts
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
@@ -104,6 +141,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
UpdateMultiCollection updateCollection) => UpdateMultiCollection updateCollection) =>
dbContext.MultiCollections dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems) .Include(mc => mc.MultiCollectionItems)
.Include(mc => mc.MultiCollectionSmartItems)
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId) .SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist.")); .Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));
@@ -118,7 +156,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Bind(_ => updateMultiCollection.NotLongerThan(50)(c => c.Name)); .Bind(_ => updateMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(updateMultiCollection.Name) var result2 = Optional(updateMultiCollection.Name)
.Filter(name => !allNames.Contains(name)) .Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique"); .ToValidation<BaseError>("MultiCollection name must be unique");
return (result1, result2).Apply((_, _) => updateMultiCollection.Name); return (result1, result2).Apply((_, _) => updateMultiCollection.Name);
@@ -13,16 +13,33 @@ namespace ErsatzTV.Application.MediaCollections
new( new(
multiCollection.Id, multiCollection.Id,
multiCollection.Name, multiCollection.Name,
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList()); Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList(),
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList());
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) => internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query); new(collection.Id, collection.Name, collection.Query);
internal static TraktListViewModel ProjectToViewModel(TraktList traktList) =>
new(
traktList.Id,
traktList.TraktId,
$"{traktList.User}/{traktList.List}",
traktList.Name,
traktList.ItemCount,
traktList.Items.Count(i => i.MediaItemId.HasValue));
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) => private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
new( new(
multiCollectionItem.MultiCollectionId, multiCollectionItem.MultiCollectionId,
ProjectToViewModel(multiCollectionItem.Collection), ProjectToViewModel(multiCollectionItem.Collection),
multiCollectionItem.ScheduleAsGroup, multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder); multiCollectionItem.PlaybackOrder);
private static MultiCollectionSmartItemViewModel ProjectToViewModel(MultiCollectionSmartItem multiCollectionSmartItem) =>
new(
multiCollectionSmartItem.MultiCollectionId,
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder);
} }
} }
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionSmartItemViewModel(
int MultiCollectionId,
SmartCollectionViewModel SmartCollection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
}
@@ -2,5 +2,9 @@
namespace ErsatzTV.Application.MediaCollections namespace ErsatzTV.Application.MediaCollections
{ {
public record MultiCollectionViewModel(int Id, string Name, List<MultiCollectionItemViewModel> Items); public record MultiCollectionViewModel(
int Id,
string Name,
List<MultiCollectionItemViewModel> Items,
List<MultiCollectionSmartItemViewModel> SmartItems);
} }
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedTraktListsViewModel(int TotalCount, List<TraktListViewModel> Page);
}
@@ -24,6 +24,8 @@ namespace ErsatzTV.Application.MediaCollections.Queries
return await dbContext.MultiCollections return await dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems) .Include(mc => mc.MultiCollectionItems)
.ThenInclude(mc => mc.Collection) .ThenInclude(mc => mc.Collection)
.Include(mc => mc.MultiCollectionSmartItems)
.ThenInclude(mc => mc.SmartCollection)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id) .SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.MapT(ProjectToViewModel); .MapT(ProjectToViewModel);
} }
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedTraktLists(int PageNum, int PageSize) : IRequest<PagedTraktListsViewModel>;
}
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetPagedTraktListsHandler : IRequestHandler<GetPagedTraktLists, PagedTraktListsViewModel>
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPagedTraktListsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<PagedTraktListsViewModel> Handle(
GetPagedTraktLists request,
CancellationToken cancellationToken)
{
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM TraktList");
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<TraktListViewModel> page = await dbContext.TraktLists.FromSqlRaw(
@"SELECT * FROM TraktList
ORDER BY Name
COLLATE NOCASE
LIMIT {0} OFFSET {1}",
request.PageSize,
request.PageNum * request.PageSize)
.Include(l => l.Items)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedTraktListsViewModel(count, page);
}
}
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaCollections
{
public record TraktListViewModel(int Id, int TraktId, string Slug, string Name, int ItemCount, int MatchCount);
}
@@ -26,6 +26,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IMovieFolderScanner _movieFolderScanner; private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner; private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner; private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler( public ScanLocalLibraryHandler(
@@ -34,6 +35,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
IMovieFolderScanner movieFolderScanner, IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner, ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner, IMusicVideoFolderScanner musicVideoFolderScanner,
IOtherVideoFolderScanner otherVideoFolderScanner,
IEntityLocker entityLocker, IEntityLocker entityLocker,
IMediator mediator, IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger) ILogger<ScanLocalLibraryHandler> logger)
@@ -43,6 +45,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
_movieFolderScanner = movieFolderScanner; _movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner; _televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner; _musicVideoFolderScanner = musicVideoFolderScanner;
_otherVideoFolderScanner = otherVideoFolderScanner;
_entityLocker = entityLocker; _entityLocker = entityLocker;
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
@@ -78,7 +81,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
decimal progressMin = (decimal) i / localLibrary.Paths.Count; decimal progressMin = (decimal) i / localLibrary.Paths.Count;
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count; decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero); var lastScan = new DateTimeOffset(libraryPath.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval); DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
if (forceScan || nextScan < DateTimeOffset.Now) if (forceScan || nextScan < DateTimeOffset.Now)
{ {
@@ -107,6 +110,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
progressMin, progressMin,
progressMax); progressMax);
break; break;
case LibraryMediaKind.OtherVideos:
await _otherVideoFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
progressMin,
progressMax);
break;
} }
libraryPath.LastScan = DateTime.UtcNow; libraryPath.LastScan = DateTime.UtcNow;
@@ -52,6 +52,21 @@ namespace ErsatzTV.Application.Playouts.Commands
.Include(p => p.ProgramSchedule) .Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items) .ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.MediaItem) .ThenInclude(psi => psi.MediaItem)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.PreRollFiller)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.MidRollFiller)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.PostRollFiller)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.TailFiller)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.FallbackFiller)
.SelectOneAsync(p => p.Id, p => p.Id == buildPlayout.PlayoutId) .SelectOneAsync(p => p.Id, p => p.Id == buildPlayout.PlayoutId)
.Map(o => o.ToValidation<BaseError>("Playout does not exist.")); .Map(o => o.ToValidation<BaseError>("Playout does not exist."));
} }
@@ -0,0 +1,10 @@
using System;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Playouts.Commands
{
public record UpdatePlayout
(int PlayoutId, Option<TimeSpan> DailyRebuildTime) : IRequest<Either<BaseError, PlayoutNameViewModel>>;
}
@@ -0,0 +1,65 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Playouts.Commands
{
public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseError, PlayoutNameViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdatePlayoutHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
UpdatePlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
}
private static async Task<PlayoutNameViewModel> ApplyUpdateRequest(
TvContext dbContext,
UpdatePlayout request,
Playout playout)
{
playout.DailyRebuildTime = null;
foreach (TimeSpan dailyRebuildTime in request.DailyRebuildTime)
{
playout.DailyRebuildTime = dailyRebuildTime;
}
await dbContext.SaveChangesAsync();
return new PlayoutNameViewModel(
playout.Id,
playout.Channel.Name,
playout.Channel.Number,
playout.ProgramSchedule.Name,
Optional(playout.DailyRebuildTime));
}
private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, UpdatePlayout request) =>
PlayoutMustExist(dbContext, request);
private static Task<Validation<BaseError, Playout>> PlayoutMustExist(
TvContext dbContext,
UpdatePlayout updatePlayout) =>
dbContext.Playouts
.Include(p => p.Channel)
.Include(p => p.ProgramSchedule)
.SelectOneAsync(p => p.Id, p => p.Id == updatePlayout.PlayoutId)
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
}
}
+19 -18
View File
@@ -8,13 +8,13 @@ namespace ErsatzTV.Application.Playouts
{ {
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) => internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new( new(
GetDisplayTitle(playoutItem.MediaItem), GetDisplayTitle(playoutItem),
playoutItem.StartOffset, playoutItem.StartOffset,
GetDisplayDuration(playoutItem.MediaItem)); GetDisplayDuration(playoutItem.FinishOffset - playoutItem.StartOffset));
private static string GetDisplayTitle(MediaItem mediaItem) private static string GetDisplayTitle(PlayoutItem playoutItem)
{ {
switch (mediaItem) switch (playoutItem.MediaItem)
{ {
case Episode e: case Episode e:
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
@@ -28,6 +28,11 @@ namespace ErsatzTV.Application.Playouts
var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}"; var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}";
var titlesString = $"{string.Join('/', episodeTitles)}"; var titlesString = $"{string.Join('/', episodeTitles)}";
if (!string.IsNullOrWhiteSpace(playoutItem.ChapterTitle))
{
titlesString += $" ({playoutItem.ChapterTitle})";
}
return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}"; return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}";
case Movie m: case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"); return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
@@ -36,25 +41,21 @@ namespace ErsatzTV.Application.Playouts
.Map(am => $"{am.Title} - ").IfNone(string.Empty); .Map(am => $"{am.Title} - ").IfNone(string.Empty);
return mv.MusicVideoMetadata.HeadOrNone() return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}") .Map(mvm => $"{artistName}{mvm.Title}")
.Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? s : $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown music video]"); .IfNone("[unknown music video]");
case OtherVideo ov:
return ov.OtherVideoMetadata.HeadOrNone()
.Map(ovm => ovm.Title ?? string.Empty)
.Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? s : $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown video]");
default: default:
return string.Empty; return string.Empty;
} }
} }
private static string GetDisplayDuration(MediaItem mediaItem) private static string GetDisplayDuration(TimeSpan duration) =>
{ string.Format(
MediaVersion version = mediaItem switch duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
{ duration);
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
return string.Format(
version.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
version.Duration);
}
} }
} }
@@ -1,8 +1,12 @@
namespace ErsatzTV.Application.Playouts using System;
using LanguageExt;
namespace ErsatzTV.Application.Playouts
{ {
public record PlayoutNameViewModel( public record PlayoutNameViewModel(
int PlayoutId, int PlayoutId,
string ChannelName, string ChannelName,
string ChannelNumber, string ChannelNumber,
string ScheduleName); string ScheduleName,
Option<TimeSpan> DailyRebuildTime);
} }
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Playouts.Queries namespace ErsatzTV.Application.Playouts.Queries
{ {
@@ -21,7 +22,13 @@ namespace ErsatzTV.Application.Playouts.Queries
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Playouts return await dbContext.Playouts
.Filter(p => p.Channel != null && p.ProgramSchedule != null) .Filter(p => p.Channel != null && p.ProgramSchedule != null)
.Map(p => new PlayoutNameViewModel(p.Id, p.Channel.Name, p.Channel.Number, p.ProgramSchedule.Name)) .Map(
p => new PlayoutNameViewModel(
p.Id,
p.Channel.Name,
p.Channel.Number,
p.ProgramSchedule.Name,
Optional(p.DailyRebuildTime)))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
} }
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Playouts.Queries
{
public record GetFuturePlayoutItemsById(int PlayoutId, bool ShowFiller, int PageNum, int PageSize) : IRequest<PagedPlayoutItemsViewModel>;
}
@@ -1,8 +1,10 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
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.Domain.Filler;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
@@ -11,21 +13,23 @@ using static ErsatzTV.Application.Playouts.Mapper;
namespace ErsatzTV.Application.Playouts.Queries namespace ErsatzTV.Application.Playouts.Queries
{ {
public class GetPlayoutItemsByIdHandler : IRequestHandler<GetPlayoutItemsById, PagedPlayoutItemsViewModel> public class GetFuturePlayoutItemsByIdHandler : IRequestHandler<GetFuturePlayoutItemsById, PagedPlayoutItemsViewModel>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbContextFactory) => public GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
public async Task<PagedPlayoutItemsViewModel> Handle( public async Task<PagedPlayoutItemsViewModel> Handle(
GetPlayoutItemsById request, GetFuturePlayoutItemsById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
DateTime now = DateTimeOffset.Now.UtcDateTime;
int totalCount = await dbContext.PlayoutItems int totalCount = await dbContext.PlayoutItems
.CountAsync(i => i.PlayoutId == request.PlayoutId, cancellationToken); .CountAsync(i => i.Finish >= now && i.PlayoutId == request.PlayoutId && (request.ShowFiller || i.FillerKind == FillerKind.None), cancellationToken);
List<PlayoutItemViewModel> page = await dbContext.PlayoutItems List<PlayoutItemViewModel> page = await dbContext.PlayoutItems
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
@@ -49,7 +53,13 @@ namespace ErsatzTV.Application.Playouts.Queries
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).Season.Show) .ThenInclude(mi => (mi as Episode).Season.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.Filter(i => i.PlayoutId == request.PlayoutId) .Filter(i => i.PlayoutId == request.PlayoutId)
.Filter(i => i.Finish >= now)
.Filter(i => request.ShowFiller || i.FillerKind == FillerKind.None)
.OrderBy(i => i.Start) .OrderBy(i => i.Start)
.Skip(request.PageNum * request.PageSize) .Skip(request.PageNum * request.PageSize)
.Take(request.PageSize) .Take(request.PageSize)
@@ -1,7 +0,0 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Playouts.Queries
{
public record GetPlayoutItemsById(int PlayoutId, int PageNum, int PageSize) : IRequest<PagedPlayoutItemsViewModel>;
}
@@ -65,7 +65,7 @@ namespace ErsatzTV.Application.Plex.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 ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now) if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{ {
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Plex;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -23,16 +24,22 @@ namespace ErsatzTV.Application.Plex.Commands
private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger; private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexTvApiClient _plexTvApiClient; private readonly IPlexTvApiClient _plexTvApiClient;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly IPlexSecretStore _plexSecretStore;
public SynchronizePlexMediaSourcesHandler( public SynchronizePlexMediaSourcesHandler(
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IPlexTvApiClient plexTvApiClient, IPlexTvApiClient plexTvApiClient,
IPlexServerApiClient plexServerApiClient,
IPlexSecretStore plexSecretStore,
ChannelWriter<IPlexBackgroundServiceRequest> channel, ChannelWriter<IPlexBackgroundServiceRequest> channel,
IEntityLocker entityLocker, IEntityLocker entityLocker,
ILogger<SynchronizePlexMediaSourcesHandler> logger) ILogger<SynchronizePlexMediaSourcesHandler> logger)
{ {
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_plexTvApiClient = plexTvApiClient; _plexTvApiClient = plexTvApiClient;
_plexServerApiClient = plexServerApiClient;
_plexSecretStore = plexSecretStore;
_channel = channel; _channel = channel;
_entityLocker = entityLocker; _entityLocker = entityLocker;
_logger = logger; _logger = logger;
@@ -69,32 +76,76 @@ namespace ErsatzTV.Application.Plex.Commands
return allExisting; return allExisting;
} }
private Task SynchronizeServer(List<PlexMediaSource> allExisting, PlexMediaSource server) private async Task SynchronizeServer(List<PlexMediaSource> allExisting, PlexMediaSource server)
{ {
Option<PlexMediaSource> maybeExisting = Option<PlexMediaSource> maybeExisting =
allExisting.Find(s => s.ClientIdentifier == server.ClientIdentifier); allExisting.Find(s => s.ClientIdentifier == server.ClientIdentifier);
return maybeExisting.Match(
existing =>
{
existing.Platform = server.Platform;
existing.PlatformVersion = server.PlatformVersion;
existing.ProductVersion = server.ProductVersion;
existing.ServerName = server.ServerName;
var toAdd = server.Connections
.Filter(connection => existing.Connections.All(c => c.Uri != connection.Uri)).ToList();
var toRemove = existing.Connections
.Filter(connection => server.Connections.All(c => c.Uri != connection.Uri)).ToList();
return _mediaSourceRepository.Update(existing, server.Connections, toAdd, toRemove);
},
async () =>
{
if (server.Connections.Any())
{
server.Connections.Head().IsActive = true;
}
await _mediaSourceRepository.Add(server); foreach (PlexMediaSource existing in maybeExisting)
}); {
existing.Platform = server.Platform;
existing.PlatformVersion = server.PlatformVersion;
existing.ProductVersion = server.ProductVersion;
existing.ServerName = server.ServerName;
var toAdd = server.Connections
.Filter(connection => existing.Connections.All(c => c.Uri != connection.Uri)).ToList();
var toRemove = existing.Connections
.Filter(connection => server.Connections.All(c => c.Uri != connection.Uri)).ToList();
await _mediaSourceRepository.Update(existing, toAdd, toRemove);
await FindConnectionToActivate(existing);
}
if (maybeExisting.IsNone)
{
await _mediaSourceRepository.Add(server);
await FindConnectionToActivate(server);
}
}
private async Task FindConnectionToActivate(PlexMediaSource server)
{
var prioritized = server.Connections.OrderBy(pc => pc.IsActive ? 0 : 1).ToList();
foreach (PlexConnection connection in server.Connections)
{
connection.IsActive = false;
}
Option<PlexServerAuthToken> maybeToken = await _plexSecretStore.GetServerAuthToken(server.ClientIdentifier);
foreach (PlexServerAuthToken token in maybeToken)
{
foreach (PlexConnection connection in prioritized)
{
try
{
_logger.LogDebug("Attempting to locate to Plex at {Uri}", connection.Uri);
if (await _plexServerApiClient.Ping(connection, token))
{
_logger.LogInformation("Located Plex at {Uri}", connection.Uri);
connection.IsActive = true;
break;
}
}
catch
{
// do nothing
}
}
}
if (maybeToken.IsNone)
{
_logger.LogError(
"Unable to activate Plex connection for server {Server} without auth token",
server.ServerName);
}
if (server.Connections.All(c => !c.IsActive))
{
_logger.LogError("Unable to locate Plex");
server.Connections.Head().IsActive = true;
}
await _mediaSourceRepository.Update(server, new List<PlexConnection>(), new List<PlexConnection>());
} }
} }
} }
@@ -19,6 +19,12 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlaybackOrder PlaybackOrder, PlaybackOrder PlaybackOrder,
int? MultipleCount, int? MultipleCount,
TimeSpan? PlayoutDuration, TimeSpan? PlayoutDuration,
bool? OfflineTail, TailMode TailMode,
string CustomTitle) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest; string CustomTitle,
GuideMode GuideMode,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
int? TailFillerId,
int? FallbackFillerId) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
} }
@@ -65,7 +65,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
.CountAsync(ps => ps.Name == createProgramSchedule.Name); .CountAsync(ps => ps.Name == createProgramSchedule.Name);
var result2 = Optional(duplicateNameCount) var result2 = Optional(duplicateNameCount)
.Filter(count => count == 0) .Where(count => count == 0)
.ToValidation<BaseError>("Schedule name must be unique"); .ToValidation<BaseError>("Schedule name must be unique");
return (result1, result2).Apply((_, _) => createProgramSchedule.Name); return (result1, result2).Apply((_, _) => createProgramSchedule.Name);
@@ -15,7 +15,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlaybackOrder PlaybackOrder { get; } PlaybackOrder PlaybackOrder { get; }
int? MultipleCount { get; } int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; } TimeSpan? PlayoutDuration { get; }
bool? OfflineTail { get; } TailMode TailMode { get; }
string CustomTitle { get; } string CustomTitle { get; }
GuideMode GuideMode { get; }
int? PreRollFillerId { get; }
int? MidRollFillerId { get; }
int? PostRollFillerId { get; }
int? TailFillerId { get; }
int? FallbackFillerId { get; }
} }
} }
@@ -1,11 +1,15 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
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.Domain.Filler;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.ProgramSchedules.Commands namespace ErsatzTV.Application.ProgramSchedules.Commands
{ {
@@ -20,6 +24,33 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
.SelectOneAsync(ps => ps.Id, ps => ps.Id == programScheduleId) .SelectOneAsync(ps => ps.Id, ps => ps.Id == programScheduleId)
.Map(o => o.ToValidation<BaseError>("[ProgramScheduleId] does not exist.")); .Map(o => o.ToValidation<BaseError>("[ProgramScheduleId] does not exist."));
protected static async Task<Either<BaseError, ProgramSchedule>> FillerConfigurationMustBeValid(
TvContext dbContext,
IProgramScheduleItemRequest item,
ProgramSchedule programSchedule)
{
var allFillerIds = Optional(item.PreRollFillerId)
.Append(Optional(item.MidRollFillerId))
.Append(Optional(item.PostRollFillerId))
.ToList();
List<FillerPreset> allFiller = await dbContext.FillerPresets
.Filter(fp => allFillerIds.Contains(fp.Id))
.ToListAsync();
if (allFiller.Count(f => f.PadToNearestMinute.HasValue) > 1)
{
return BaseError.New("Schedule may only contain one filler preset that is configured to pad");
}
if (allFiller.Any(fp => fp.PadToNearestMinute.HasValue) && !item.FallbackFillerId.HasValue)
{
return BaseError.New("Fallback filler is required when padding");
}
return programSchedule;
}
protected static Validation<BaseError, ProgramSchedule> PlayoutModeMustBeValid( protected static Validation<BaseError, ProgramSchedule> PlayoutModeMustBeValid(
IProgramScheduleItemRequest item, IProgramScheduleItemRequest item,
ProgramSchedule programSchedule) ProgramSchedule programSchedule)
@@ -55,9 +86,14 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[PlayoutDuration] is required for playout mode 'duration'"); return BaseError.New("[PlayoutDuration] is required for playout mode 'duration'");
} }
if (item.OfflineTail is null) if (item.TailMode == TailMode.Filler && item.TailFillerId == null)
{ {
return BaseError.New("[OfflineTail] is required for playout mode 'duration'"); return BaseError.New("Tail Filler is required with tail mode Filler");
}
if (item.TailFillerId != null && item.TailMode != TailMode.Filler)
{
return BaseError.New("Tail Filler will not be used unless tail mode is set to Filler");
} }
break; break;
@@ -133,33 +169,45 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
{ {
ProgramScheduleId = programSchedule.Id, ProgramScheduleId = programSchedule.Id,
Index = index, Index = index,
StartTime = item.StartTime, StartTime = FixStartTime(item.StartTime),
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId, MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId, SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId, MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder, PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
PreRollFillerId = item.PreRollFillerId,
MidRollFillerId = item.MidRollFillerId,
PostRollFillerId = item.PostRollFillerId,
TailFillerId = item.TailFillerId,
FallbackFillerId = item.FallbackFillerId
}, },
PlayoutMode.One => new ProgramScheduleItemOne PlayoutMode.One => new ProgramScheduleItemOne
{ {
ProgramScheduleId = programSchedule.Id, ProgramScheduleId = programSchedule.Id,
Index = index, Index = index,
StartTime = item.StartTime, StartTime = FixStartTime(item.StartTime),
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId, MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId, SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId, MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder, PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
PreRollFillerId = item.PreRollFillerId,
MidRollFillerId = item.MidRollFillerId,
PostRollFillerId = item.PostRollFillerId,
TailFillerId = item.TailFillerId,
FallbackFillerId = item.FallbackFillerId
}, },
PlayoutMode.Multiple => new ProgramScheduleItemMultiple PlayoutMode.Multiple => new ProgramScheduleItemMultiple
{ {
ProgramScheduleId = programSchedule.Id, ProgramScheduleId = programSchedule.Id,
Index = index, Index = index,
StartTime = item.StartTime, StartTime = FixStartTime(item.StartTime),
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId, MultiCollectionId = item.MultiCollectionId,
@@ -167,13 +215,19 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
MediaItemId = item.MediaItemId, MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder, PlaybackOrder = item.PlaybackOrder,
Count = item.MultipleCount.GetValueOrDefault(), Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
PreRollFillerId = item.PreRollFillerId,
MidRollFillerId = item.MidRollFillerId,
PostRollFillerId = item.PostRollFillerId,
TailFillerId = item.TailFillerId,
FallbackFillerId = item.FallbackFillerId
}, },
PlayoutMode.Duration => new ProgramScheduleItemDuration PlayoutMode.Duration => new ProgramScheduleItemDuration
{ {
ProgramScheduleId = programSchedule.Id, ProgramScheduleId = programSchedule.Id,
Index = index, Index = index,
StartTime = item.StartTime, StartTime = FixStartTime(item.StartTime),
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId, MultiCollectionId = item.MultiCollectionId,
@@ -181,13 +235,24 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
MediaItemId = item.MediaItemId, MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder, PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()), PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
OfflineTail = item.OfflineTail.GetValueOrDefault(), TailMode = item.TailMode,
CustomTitle = item.CustomTitle CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
PreRollFillerId = item.PreRollFillerId,
MidRollFillerId = item.MidRollFillerId,
PostRollFillerId = item.PostRollFillerId,
TailFillerId = item.TailFillerId,
FallbackFillerId = item.FallbackFillerId
}, },
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}") _ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
}; };
private static TimeSpan FixDuration(TimeSpan duration) => private static TimeSpan FixDuration(TimeSpan duration) =>
duration > TimeSpan.FromDays(1) ? duration.Subtract(TimeSpan.FromDays(1)) : duration; duration >= TimeSpan.FromDays(1) ? duration.Subtract(TimeSpan.FromDays(1)) : duration;
private static TimeSpan? FixStartTime(TimeSpan? startTime) =>
startTime.HasValue && startTime.Value >= TimeSpan.FromDays(1)
? startTime.Value.Subtract(TimeSpan.FromDays(1))
: startTime;
} }
} }
@@ -20,8 +20,14 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlaybackOrder PlaybackOrder, PlaybackOrder PlaybackOrder,
int? MultipleCount, int? MultipleCount,
TimeSpan? PlayoutDuration, TimeSpan? PlayoutDuration,
bool? OfflineTail, TailMode TailMode,
string CustomTitle) : IProgramScheduleItemRequest; string CustomTitle,
GuideMode GuideMode,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
int? TailFillerId,
int? FallbackFillerId) : IProgramScheduleItemRequest;
public record ReplaceProgramScheduleItems public record ReplaceProgramScheduleItems
(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest< (int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<

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