Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f90f5d44 | ||
|
|
bda4117655 | ||
|
|
3240703840 | ||
|
|
53a7570ba3 | ||
|
|
0e789fd6d8 | ||
|
|
0136de700c | ||
|
|
2ea0e64ac1 | ||
|
|
5993f23ec5 | ||
|
|
417f35a834 | ||
|
|
a74547997d | ||
|
|
a2f74dd284 | ||
|
|
373daf9ce6 | ||
|
|
68693cffa0 | ||
|
|
6d147de2f3 | ||
|
|
f4a63a1a1a | ||
|
|
bc9d17ca25 | ||
|
|
42e13cbbaf | ||
|
|
6cc61f3212 | ||
|
|
4cf44616a8 | ||
|
|
33aaadae68 | ||
|
|
fe3f8e391e | ||
|
|
1a68dd040a | ||
|
|
67761c1a14 | ||
|
|
1802f9d797 | ||
|
|
69354c9296 | ||
|
|
0021e21b50 | ||
|
|
cdf7765059 | ||
|
|
71658c448f | ||
|
|
3ecdd741a5 | ||
|
|
0daeb844b9 | ||
|
|
22da19845b | ||
|
|
3a6d9e9f39 | ||
|
|
7ed4b8ae3c | ||
|
|
be7311e620 | ||
|
|
03be372070 | ||
|
|
d196308ee9 | ||
|
|
3d68b0f055 | ||
|
|
37e32f06ad | ||
|
|
c43ca2837d | ||
|
|
992121f308 | ||
|
|
04adbfeffa | ||
|
|
1fc905c6ad | ||
|
|
4b5dff2159 | ||
|
|
2a5edf8214 | ||
|
|
69912c8cae | ||
|
|
fd3de2d82a | ||
|
|
6ba9404752 | ||
|
|
db080375c5 | ||
|
|
9abc7ad8b7 | ||
|
|
9e531a82d7 | ||
|
|
d84bd2b948 | ||
|
|
d7d3ec1235 | ||
|
|
742ac21ad7 | ||
|
|
819b55e21f | ||
|
|
cf5718c288 | ||
|
|
adc7982955 | ||
|
|
67a6f554d0 | ||
|
|
609df217ae | ||
|
|
d3086264c7 | ||
|
|
8cd9b23787 | ||
|
|
dc5c9e42ff | ||
|
|
2dd267e4db | ||
|
|
b069a21473 | ||
|
|
6c8813ce22 | ||
|
|
b5de5e2b7f | ||
|
|
4b7da4e468 | ||
|
|
ae8e795228 | ||
|
|
334781485d | ||
|
|
27fefa1b38 | ||
|
|
fc3175591e | ||
|
|
3363d2c9d7 | ||
|
|
1d5217fa84 | ||
|
|
904cdb8780 | ||
|
|
85fee64565 | ||
|
|
13cfb9728f | ||
|
|
60b82876ea | ||
|
|
a99249c375 | ||
|
|
36e6ef4c18 | ||
|
|
21e53532c1 | ||
|
|
a864d53327 | ||
|
|
e6446f9983 | ||
|
|
ad40213f90 | ||
|
|
45c6d20fd0 | ||
|
|
5439db89a7 | ||
|
|
a39231bb5a | ||
|
|
4c8584b517 | ||
|
|
ca8bcacbd3 | ||
|
|
f27286d1dd | ||
|
|
23870b75f7 | ||
|
|
7f5a91c643 | ||
|
|
f1f50e883c | ||
|
|
7506f49f5b | ||
|
|
944f1e4307 | ||
|
|
f7de9ac5ea | ||
|
|
1eb51ad2f4 | ||
|
|
c3e0aaf0b7 | ||
|
|
b9912b47df | ||
|
|
55fb2624e7 | ||
|
|
8ced20dc39 | ||
|
|
e718cb0faf | ||
|
|
e218ff9a6d | ||
|
|
c2a49cbaea | ||
|
|
17e74f7314 | ||
|
|
2032bb4777 | ||
|
|
7877ec641e | ||
|
|
767a9779bb | ||
|
|
bb9127e546 | ||
|
|
c932577cb8 | ||
|
|
ad2685fb2e |
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
+15
-5
@@ -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
Reference in New Issue
Block a user