Compare commits

...

104 Commits

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

* add naive filler scheduling

* fix duration item length in xmltv

* show offline image for unfilled duration tail

* fix tests

* update changelog

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

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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: Serilog.Settings.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-08 09:09:57 -05:00
Jason Dove
2dd267e4db fix xmltv generation with missing episode metadata (#402) 2021-10-07 22:31:46 -05:00
Jason Dove
b069a21473 allow hls segmenter to run before framerate is known (#401) 2021-10-07 22:07:54 -05:00
Jason Dove
6c8813ce22 add hls segmenter streaming mode (#400)
* hls segmenter wip

* log message

* close unused transcode sessions after 2 minutes

* use frame rate for 2s keyframes in hls segmenter

* add frame rate to media version

* fix segmenter framerate calculation

* automatically restart hls segmenter with next scheduled item

* cleanup

* update changelog

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

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

* cleanup to reduce image size

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

* dont use full paths for transcoding tests

* add error details

* use 1-second videos for transcoding tests

* vaapi fixes

* include format in scale_vaapi

* more vaapi fixes

* unsupported errors

* fix unsupported checks

* maybe not failure?

* fix formatting

* ignore nvdec warnings

* update changelog

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

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

* update dependencies

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

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

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

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

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

* implement scheduled playout rebuild

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

* tweak changelog
2021-09-17 21:13:27 -05:00
Jason Dove
1eb51ad2f4 add some health checks to home page (#374) 2021-09-17 17:59:59 -05:00
Jason Dove
c3e0aaf0b7 missing metadata fixes (#373) 2021-09-17 08:53:41 -05:00
Jason Dove
b9912b47df update changelog for release 58 [no ci] 2021-09-15 21:53:34 -05:00
Jason Dove
55fb2624e7 add multi-part grouping tooltip (#371) 2021-09-15 21:18:41 -05:00
Jason Dove
8ced20dc39 dont offset collections during shuffle in order (#370) 2021-09-15 20:54:32 -05:00
Jason Dove
e718cb0faf fix building playouts in timezones with positive offsets (#368) 2021-09-15 09:07:35 -05:00
Jason Dove
e218ff9a6d fix watermark when no video filters are required (#367) 2021-09-15 05:12:23 -05:00
Jason Dove
c2a49cbaea update dependencies (#365) 2021-09-14 18:10:59 -05:00
Jason Dove
17e74f7314 add more release date search options (#362) 2021-09-12 18:32:38 -05:00
Long-Man
2032bb4777 Update search.md (#361)
Add release_date and released_nointhelast to music video search
2021-09-12 12:18:07 -05:00
Jason Dove
7877ec641e update changelog for release 57 [no ci] 2021-09-11 10:37:23 -05:00
Jason Dove
767a9779bb more kodi artwork fixes (#360) 2021-09-11 10:08:33 -05:00
Jason Dove
bb9127e546 fix artwork in kodi (#359) 2021-09-11 09:45:09 -05:00
Jason Dove
c932577cb8 allow adding smart collections to multi collections (#358) 2021-09-11 09:29:20 -05:00
Jason Dove
ad2685fb2e add released_inthelast queries (#357) 2021-09-10 21:11:14 -05:00
Jason Dove
96bc2c28f2 update changelog for release 56 [no ci] 2021-09-10 13:59:14 -05:00
Jason Dove
a076b3eb30 add shuffle-in-order support to all collections (#356) 2021-09-10 13:33:04 -05:00
Jason Dove
fc360602ad add smart collections (#355)
* start to add smart collections

* add smart collection table; delete smart collection

* overwrite smart collections

* support scheduling smart collections

* update changelog
2021-09-10 11:58:24 -05:00
Jason Dove
d8b4d00a73 clarify changelog [no ci] 2021-09-09 08:22:46 -05:00
Jason Dove
0638ac8a5e more missing metadata fixes (#354)
* more missing metadata fixes

* update mudblazor
2021-09-09 06:38:46 -05:00
Jason Dove
f1f09bd4cb fix sorting episodes without metadata (#353) 2021-09-08 22:12:47 -05:00
Jason Dove
f6680f29e7 try to fix doc formatting [no docker] 2021-09-07 13:36:25 -05:00
Jason Dove
1c0413452b fix m3u xmltv mapping 2021-09-07 06:34:17 -05:00
Jason Dove
77308a9ac5 generate valid xmltv (#351) 2021-09-07 06:12:13 -05:00
Jason Dove
3ea8193bb3 update changelog for release 55 [no ci] 2021-09-03 08:56:25 -05:00
Jason Dove
8ad8680027 update dependencies; fix unnecessary table scrolling (#347) 2021-09-03 06:22:50 -05:00
Jason Dove
640044814c ignore dot-underscore files (#346) 2021-09-03 06:22:33 -05:00
Jason Dove
18b5313a53 update docs [no docker] 2021-08-22 20:17:39 -05:00
Jason Dove
8417c3f6cd update changelog for release 54 [no ci] 2021-08-21 13:19:13 -05:00
Jason Dove
32fdb414fa add "shuffle in order" playback order for multi-collections (#338)
* add "shuffle in order" option for multi-collections

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

* fix fluent assertions
2021-08-21 06:23:43 -05:00
Jason Dove
9d07627781 fix ffprobe parsing in some cultures (#337) 2021-08-21 05:57:39 -05:00
Jason Dove
d3c8914758 update dependencies (#331) 2021-08-14 07:20:09 -05:00
368 changed files with 66387 additions and 788 deletions

View File

@@ -49,7 +49,7 @@ jobs:
tag=$(git describe --tags --abbrev=0)
tag2="${tag:1}"
short=$(git rev-parse --short HEAD)
final="${tag2/prealpha/$short}"
final="${tag2/alpha/$short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Set up Docker Buildx Base

View File

@@ -1,71 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '30 3 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'csharp' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@@ -82,7 +82,7 @@ jobs:
run: |
tag=$(git describe --tags --abbrev=0)
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
uses: docker/setup-buildx-action@v1

View File

@@ -4,6 +4,184 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- Fix double scheduling; this could happen if the app was shutdown during a playout build
## [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
### Added
- Add Smart Collections
- Smart Collections use search queries and can be created from the search result page
- Smart Collections are re-evaluated every time playouts are extended or rebuilt to automatically include newly-matching items
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
- Allow `Shuffle In Order` with Collections and Smart Collections
- Episodes will be grouped by show, and music videos will be grouped by artist
- All movies will be a single group (multi-collections are probably better if `Shuffle In Order` is desired for movies)
- All groups will be be ordered chronologically (custom ordering is only supported in multi-collections)
### Fixed
- Generate XMLTV that validates successfully
- Properly order elements
- Omit channels with no programmes
- Properly identify channels using the format number.etv like `15.etv`
- Fix building playouts when multi-part episode grouping is enabled and episodes are missing metadata
- Fix incorrect total items count in `Multi Collections` table
## [0.0.55-alpha] - 2021-09-03
### Fixed
- Fix all local library scanners to ignore dot underscore files (`._`)
## [0.0.54-alpha] - 2021-08-21
### Added
- Add `Shuffle In Order` playback order for multi-collections.
- This is useful for randomizing multiple collections/shows on a single channel, while each collection maintains proper ordering (custom or chronological)
### Fixed
- Fix bug parsing ffprobe output in cultures where `.` is a group/thousands separator
- This bug likely prevented ETV from scheduling correctly or working at all in those cultures
- After installing a version with this fix, affected content will need to be removed from ETV and re-added
## [0.0.53-alpha] - 2021-08-01
### Fixed
@@ -536,7 +714,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.4-alpha...HEAD
[0.1.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.3-alpha...v0.1.4-alpha
[0.1.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.2-alpha...v0.1.3-alpha
[0.1.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.1-alpha...v0.1.2-alpha
[0.1.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-alpha...v0.1.1-alpha
[0.1.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...v0.1.0-alpha
[0.0.62-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
[0.0.61-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
[0.0.60-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...v0.0.60-alpha
[0.0.59-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.58-alpha
[0.0.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
[0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
[0.0.54-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
[0.0.53-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.52-alpha...v0.0.53-alpha
[0.0.52-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.51-alpha...v0.0.52-alpha
[0.0.51-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.50-alpha...v0.0.51-alpha

View File

@@ -28,6 +28,10 @@ namespace ErsatzTV.Application.Channels.Queries
{
switch (mode.ToLowerInvariant())
{
case "segmenter":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);

View File

@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Emby.Commands
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);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{

View File

@@ -7,13 +7,9 @@
</PropertyGroup>
<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="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>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using LanguageExt;
using MediatR;
@@ -10,6 +11,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId,
bool NormalizeVideo,
string VideoCodec,

View File

@@ -45,6 +45,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
ThreadCount = threadCount,
Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
ResolutionId = resolutionId,
NormalizeVideo = request.NormalizeVideo,
VideoCodec = request.VideoCodec,

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using LanguageExt;
using MediatR;
@@ -11,6 +12,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId,
bool NormalizeVideo,
string VideoCodec,

View File

@@ -36,18 +36,20 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.ThreadCount = update.ThreadCount;
p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.ResolutionId = update.ResolutionId;
p.NormalizeVideo = update.NormalizeVideo;
p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
p.VideoCodec = update.VideoCodec;
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.AudioCodec = update.AudioCodec;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudness = update.NormalizeLoudness;
p.NormalizeLoudness = update.Transcode && update.NormalizeLoudness;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.NormalizeAudio;
p.NormalizeAudio = update.Transcode && update.NormalizeAudio;
await dbContext.SaveChangesAsync();
return new UpdateFFmpegProfileResult(p.Id);
}

View File

@@ -115,6 +115,14 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
}
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegSegmenterTimeout,
request.Settings.HlsSegmenterIdleTimeout);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegWorkAheadSegmenters,
request.Settings.WorkAheadSegmenterLimit);
return Unit.Default;
}
}

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.FFmpegProfiles
{
@@ -9,6 +10,8 @@ namespace ErsatzTV.Application.FFmpegProfiles
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
ResolutionViewModel Resolution,
bool NormalizeVideo,
string VideoCodec,

View File

@@ -8,5 +8,7 @@
public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; }
public int HlsSegmenterIdleTimeout { get; set; }
public int WorkAheadSegmenterLimit { get; set; }
}
}

View File

@@ -12,6 +12,8 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.ThreadCount,
profile.Transcode,
profile.HardwareAcceleration,
profile.VaapiDriver,
profile.VaapiDevice,
Project(profile.Resolution),
profile.NormalizeVideo,
profile.VideoCodec,

View File

@@ -28,6 +28,10 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
Option<int> hlsSegmenterIdleTimeout =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout);
Option<int> workAheadSegmenterLimit =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
var result = new FFmpegSettingsViewModel
{
@@ -36,6 +40,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false),
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
};
foreach (int watermarkId in watermark)

View File

@@ -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>>;
}

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IFFmpegWorkerRequest
{
}
}

View File

@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
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);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
@@ -8,6 +9,7 @@ using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
@@ -22,15 +24,18 @@ namespace ErsatzTV.Application.Libraries.Commands
{
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IEntityLocker _entityLocker;
private readonly ISearchIndex _searchIndex;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IEntityLocker entityLocker,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_entityLocker = entityLocker;
_searchIndex = searchIndex;
_dbContextFactory = dbContextFactory;
}
@@ -56,10 +61,21 @@ namespace ErsatzTV.Application.Libraries.Commands
.Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path)))
.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.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))
{

View File

@@ -9,7 +9,8 @@ namespace ErsatzTV.Application.MediaCards
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards)
List<MusicVideoCardViewModel> MusicVideoCards,
List<OtherVideoCardViewModel> OtherVideoCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}

View File

@@ -1,5 +1,5 @@
using System;
using System.Linq;
using System.Linq;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
@@ -20,7 +20,7 @@ namespace ErsatzTV.Application.MediaCards
showMetadata.Year?.ToString(),
showMetadata.SortTitle,
GetPoster(showMetadata, maybeJellyfin, maybeEmby));
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
Season season,
Option<JellyfinMediaSource> maybeJellyfin,
@@ -36,6 +36,26 @@ namespace ErsatzTV.Application.MediaCards
.IfNone(string.Empty),
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(
EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin,
@@ -43,7 +63,7 @@ namespace ErsatzTV.Application.MediaCards
bool isSearchResult) =>
new(
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
episodeMetadata.ReleaseDate ?? SystemTime.MinValueUtc,
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
m => m.Title ?? string.Empty,
() => string.Empty),
@@ -80,8 +100,16 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
musicVideoMetadata.Album,
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) =>
new(
artistMetadata.ArtistId,
@@ -112,6 +140,8 @@ namespace ErsatzTV.Application.MediaCards
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
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 };
internal static ActorCardViewModel ProjectToViewModel(

View File

@@ -7,6 +7,7 @@
string Subtitle,
string SortTitle,
string Plot,
string Album,
string Poster) : MediaCardViewModel(
MusicVideoId,
Title,

View File

@@ -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);
}

View File

@@ -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; }
}
}

View File

@@ -80,6 +80,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season)
.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)
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));

View File

@@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries
{
@@ -41,7 +42,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
return new TelevisionSeasonCardResultsViewModel(count, results);
return new TelevisionSeasonCardResultsViewModel(count, results, None);
}
}
}

View File

@@ -1,6 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionSeasonCardResultsViewModel(int Count, List<TelevisionSeasonCardViewModel> Cards);
public record TelevisionSeasonCardResultsViewModel(
int Count,
List<TelevisionSeasonCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}

View File

@@ -9,7 +9,9 @@ namespace ErsatzTV.Application.MediaCollections.Commands
int CollectionId,
List<int> MovieIds,
List<int> ShowIds,
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
List<int> MusicVideoIds,
List<int> OtherVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -52,9 +52,11 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
var allItems = request.MovieIds
.Append(request.ShowIds)
.Append(request.SeasonIds)
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.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;
}
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 ValidateMovies(request),
await ValidateShows(request),
await ValidateSeasons(request),
await ValidateEpisodes(request))
.Apply((collection, _, _, _) => collection);
.Apply((collection, _, _, _, _) => collection);
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext,
@@ -106,6 +111,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => Unit.Default)
.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) =>
_televisionRepository.AllEpisodesExist(request.EpisodeIds)
.Map(Optional)

View File

@@ -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>>;
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -6,7 +6,7 @@ using MediatR;
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
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;

View File

@@ -41,6 +41,11 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Query()
.Include(i => i.Collection)
.LoadAsync();
await dbContext.Entry(multiCollection)
.Collection(c => c.MultiCollectionSmartItems)
.Query()
.Include(i => i.SmartCollection)
.LoadAsync();
return ProjectToViewModel(multiCollection);
}
@@ -51,12 +56,40 @@ namespace ErsatzTV.Application.MediaCollections.Commands
name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
}).ToList()
MultiCollectionItems = request.Items.Bind(
i =>
{
if (i.CollectionId.HasValue)
{
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(

View File

@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateSmartCollection
(string Query, string Name) : IRequest<Either<BaseError, SmartCollectionViewModel>>;
}

View File

@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class CreateSmartCollectionHandler :
IRequestHandler<CreateSmartCollection, Either<BaseError, SmartCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, SmartCollectionViewModel>> Handle(
CreateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<SmartCollectionViewModel> PersistCollection(
TvContext dbContext,
SmartCollection smartCollection)
{
await dbContext.SmartCollections.AddAsync(smartCollection);
await dbContext.SaveChangesAsync();
return ProjectToViewModel(smartCollection);
}
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
CreateSmartCollection request) =>
ValidateName(dbContext, request).MapT(
name => new SmartCollection
{
Name = name,
Query = request.Query
});
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
CreateSmartCollection createSmartCollection)
{
List<string> allNames = await dbContext.SmartCollections
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createSmartCollection.NotEmpty(c => c.Name)
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createSmartCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("SmartCollection name must be unique");
return (result1, result2).Apply((_, _) => createSmartCollection.Name);
}
}
}

View File

@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record DeleteSmartCollection(int SmartCollectionId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -0,0 +1,42 @@
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 Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class DeleteSmartCollectionHandler : MediatR.IRequestHandler<DeleteSmartCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Unit>> Handle(
DeleteSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
{
dbContext.SmartCollections.Remove(smartCollection);
return dbContext.SaveChangesAsync().ToUnit();
}
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
DeleteSmartCollection request) =>
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId)
.Map(o => o.ToValidation<BaseError>($"SmartCollection {request.SmartCollectionId} does not exist."));
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -5,7 +5,7 @@ using LanguageExt;
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
(

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
@@ -44,15 +45,17 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
c.Name = request.Name;
// save name first so playouts don't get rebuild for a name change
// save name first so playouts don't get rebuilt for a name change
await dbContext.SaveChangesAsync();
var toAdd = request.Items
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId))
.Map(
i => new MultiCollectionItem
.Filter(i => i.CollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException
.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,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
@@ -79,6 +82,40 @@ namespace ErsatzTV.Application.MediaCollections.Commands
// add new items
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
if (await dbContext.SaveChangesAsync() > 0)
{
@@ -104,6 +141,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
UpdateMultiCollection updateCollection) =>
dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.Include(mc => mc.MultiCollectionSmartItems)
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));

View File

@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateSmartCollection(int Id, string Query) : IRequest<Either<BaseError, Unit>>;
}

View File

@@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateSmartCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, SmartCollection c, UpdateSmartCollection request)
{
c.Query = request.Query;
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
UpdateSmartCollection request) => SmartCollectionMustExist(dbContext, request);
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
UpdateSmartCollection updateCollection) =>
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id)
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
}
}

View File

@@ -13,7 +13,20 @@ namespace ErsatzTV.Application.MediaCollections
new(
multiCollection.Id,
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) =>
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) =>
new(
@@ -21,5 +34,12 @@ namespace ErsatzTV.Application.MediaCollections
ProjectToViewModel(multiCollectionItem.Collection),
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder);
private static MultiCollectionSmartItemViewModel ProjectToViewModel(MultiCollectionSmartItem multiCollectionSmartItem) =>
new(
multiCollectionSmartItem.MultiCollectionId,
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder);
}
}

View File

@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionSmartItemViewModel(
int MultiCollectionId,
SmartCollectionViewModel SmartCollection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
}

View File

@@ -2,5 +2,9 @@
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);
}

View File

@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedSmartCollectionsViewModel(int TotalCount, List<SmartCollectionViewModel> Page);
}

View File

@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedTraktListsViewModel(int TotalCount, List<TraktListViewModel> Page);
}

View File

@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetAllSmartCollections : IRequest<List<SmartCollectionViewModel>>;
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetAllSmartCollectionsHandler : IRequestHandler<GetAllSmartCollections, List<SmartCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<SmartCollectionViewModel>> Handle(
GetAllSmartCollections request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.SmartCollections
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}
}

View File

@@ -24,6 +24,8 @@ namespace ErsatzTV.Application.MediaCollections.Queries
return await dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.ThenInclude(mc => mc.Collection)
.Include(mc => mc.MultiCollectionSmartItems)
.ThenInclude(mc => mc.SmartCollection)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.MapT(ProjectToViewModel);
}

View File

@@ -27,7 +27,7 @@ namespace ErsatzTV.Application.MediaCollections.Queries
GetPagedMultiCollections request,
CancellationToken cancellationToken)
{
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM Collection");
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM MultiCollection");
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<MultiCollectionViewModel> page = await dbContext.MultiCollections.FromSqlRaw(

View File

@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedSmartCollections(int PageNum, int PageSize) : IRequest<PagedSmartCollectionsViewModel>;
}

View File

@@ -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 LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetPagedSmartCollectionsHandler : IRequestHandler<GetPagedSmartCollections, PagedSmartCollectionsViewModel>
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<PagedSmartCollectionsViewModel> Handle(
GetPagedSmartCollections request,
CancellationToken cancellationToken)
{
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM SmartCollection");
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<SmartCollectionViewModel> page = await dbContext.SmartCollections.FromSqlRaw(
@"SELECT * FROM SmartCollection
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 PagedSmartCollectionsViewModel(count, page);
}
}
}

View File

@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedTraktLists(int PageNum, int PageSize) : IRequest<PagedTraktListsViewModel>;
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaCollections
{
public record SmartCollectionViewModel(int Id, string Name, string Query);
}

View File

@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaCollections
{
public record TraktListViewModel(int Id, int TraktId, string Slug, string Name, int ItemCount, int MatchCount);
}

View File

@@ -26,6 +26,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private readonly IMediator _mediator;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler(
@@ -34,6 +35,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner,
IOtherVideoFolderScanner otherVideoFolderScanner,
IEntityLocker entityLocker,
IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger)
@@ -43,6 +45,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_otherVideoFolderScanner = otherVideoFolderScanner;
_entityLocker = entityLocker;
_mediator = mediator;
_logger = logger;
@@ -78,7 +81,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
decimal progressMin = (decimal) i / 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);
if (forceScan || nextScan < DateTimeOffset.Now)
{
@@ -107,6 +110,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
progressMin,
progressMax);
break;
case LibraryMediaKind.OtherVideos:
await _otherVideoFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
progressMin,
progressMax);
break;
}
libraryPath.LastScan = DateTime.UtcNow;

View File

@@ -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>>;
}

View File

@@ -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."));
}
}

View File

@@ -37,6 +37,9 @@ namespace ErsatzTV.Application.Playouts
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
case OtherVideo ov:
return ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Title ?? string.Empty)
.IfNone("[unknown video]");
default:
return string.Empty;
}
@@ -49,6 +52,7 @@ namespace ErsatzTV.Application.Playouts
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};

View File

@@ -1,8 +1,12 @@
namespace ErsatzTV.Application.Playouts
using System;
using LanguageExt;
namespace ErsatzTV.Application.Playouts
{
public record PlayoutNameViewModel(
int PlayoutId,
string ChannelName,
string ChannelNumber,
string ScheduleName);
string ScheduleName,
Option<TimeSpan> DailyRebuildTime);
}

View File

@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Playouts.Queries
{
@@ -21,7 +22,13 @@ namespace ErsatzTV.Application.Playouts.Queries
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Playouts
.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);
}
}

View File

@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Playouts.Queries
{
public record GetFuturePlayoutItemsById(int PlayoutId, int PageNum, int PageSize) : IRequest<PagedPlayoutItemsViewModel>;
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -11,21 +12,23 @@ using static ErsatzTV.Application.Playouts.Mapper;
namespace ErsatzTV.Application.Playouts.Queries
{
public class GetPlayoutItemsByIdHandler : IRequestHandler<GetPlayoutItemsById, PagedPlayoutItemsViewModel>
public class GetFuturePlayoutItemsByIdHandler : IRequestHandler<GetFuturePlayoutItemsById, PagedPlayoutItemsViewModel>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<PagedPlayoutItemsViewModel> Handle(
GetPlayoutItemsById request,
GetFuturePlayoutItemsById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
int totalCount = await dbContext.PlayoutItems
.CountAsync(i => i.PlayoutId == request.PlayoutId, cancellationToken);
DateTime now = DateTimeOffset.Now.UtcDateTime;
List<PlayoutItemViewModel> page = await dbContext.PlayoutItems
.Include(i => i.MediaItem)
@@ -49,7 +52,12 @@ namespace ErsatzTV.Application.Playouts.Queries
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).Season.Show)
.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.Finish >= now)
.OrderBy(i => i.Start)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)

View File

@@ -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>;
}

View File

@@ -65,7 +65,7 @@ namespace ErsatzTV.Application.Plex.Commands
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);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{

View File

@@ -14,10 +14,17 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail,
string CustomTitle) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
TailMode TailMode,
ProgramScheduleItemCollectionType TailCollectionType,
int? TailCollectionId,
int? TailMultiCollectionId,
int? TailSmartCollectionId,
int? TailMediaItemId,
string CustomTitle,
GuideMode GuideMode) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
}

View File

@@ -9,12 +9,19 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType { get; }
int? CollectionId { get; }
int? MultiCollectionId { get; }
int? SmartCollectionId { get; }
int? MediaItemId { get; }
PlayoutMode PlayoutMode { get; }
PlaybackOrder PlaybackOrder { get; }
int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; }
bool? OfflineTail { get; }
TailMode TailMode { get; }
ProgramScheduleItemCollectionType TailCollectionType { get; }
int? TailCollectionId { get; }
int? TailMultiCollectionId { get; }
int? TailSmartCollectionId { get; }
int? TailMediaItemId { get; }
string CustomTitle { get; }
GuideMode GuideMode { get; }
}
}

View File

@@ -24,6 +24,19 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
IProgramScheduleItemRequest item,
ProgramSchedule programSchedule)
{
if (item.MultiCollectionId.HasValue)
{
switch (item.PlaybackOrder)
{
case PlaybackOrder.Chronological:
case PlaybackOrder.Random:
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
case PlaybackOrder.Shuffle:
case PlaybackOrder.ShuffleInOrder:
break;
}
}
switch (item.PlayoutMode)
{
case PlayoutMode.Flood:
@@ -42,11 +55,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[PlayoutDuration] is required for playout mode 'duration'");
}
if (item.OfflineTail is null)
{
return BaseError.New("[OfflineTail] is required for playout mode 'duration'");
}
break;
default:
return BaseError.New("[PlayoutMode] is invalid");
@@ -95,6 +103,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[MultiCollection] is required for collection type 'MultiCollection'");
}
break;
case ProgramScheduleItemCollectionType.SmartCollection:
if (item.SmartCollectionId is null)
{
return BaseError.New("[SmartCollection] is required for collection type 'SmartCollection'");
}
break;
default:
return BaseError.New("[CollectionType] is invalid");
@@ -117,9 +132,11 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
PlayoutMode.One => new ProgramScheduleItemOne
{
@@ -129,9 +146,11 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
PlayoutMode.Multiple => new ProgramScheduleItemMultiple
{
@@ -141,10 +160,12 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
PlayoutMode.Duration => new ProgramScheduleItemDuration
{
@@ -154,11 +175,18 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
OfflineTail = item.OfflineTail.GetValueOrDefault(),
CustomTitle = item.CustomTitle
TailMode = item.TailMode,
TailCollectionType = item.TailCollectionType,
TailCollectionId = item.TailCollectionId,
TailMultiCollectionId = item.TailMultiCollectionId,
TailSmartCollectionId = item.TailSmartCollectionId,
TailMediaItemId = item.TailMediaItemId,
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};

View File

@@ -15,12 +15,19 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail,
string CustomTitle) : IProgramScheduleItemRequest;
TailMode TailMode,
ProgramScheduleItemCollectionType TailCollectionType,
int? TailCollectionId,
int? TailMultiCollectionId,
int? TailSmartCollectionId,
int? TailMediaItemId,
string CustomTitle,
GuideMode GuideMode) : IProgramScheduleItemRequest;
public record ReplaceProgramScheduleItems
(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<

View File

@@ -88,7 +88,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
item.CollectionType,
item.CollectionId,
item.MediaItemId,
item.MultiCollectionId);
item.MultiCollectionId,
item.SmartCollectionId);
if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet<PlaybackOrder> playbackOrders))
{
@@ -111,8 +112,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId);
private record CollectionKeyOrder(CollectionKey Key, PlaybackOrder PlaybackOrder);
int? MultiCollectionId,
int? SmartCollectionId);
}
}

View File

@@ -28,6 +28,9 @@ namespace ErsatzTV.Application.ProgramSchedules
duration.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.MultiCollection)
: null,
duration.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.SmartCollection)
: null,
duration.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -37,8 +40,26 @@ namespace ErsatzTV.Application.ProgramSchedules
},
duration.PlaybackOrder,
duration.PlayoutDuration,
duration.OfflineTail,
duration.CustomTitle),
duration.TailMode,
duration.TailCollectionType,
duration.TailCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.TailCollection)
: null,
duration.TailMultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.TailMultiCollection)
: null,
duration.TailSmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.TailSmartCollection)
: null,
duration.TailMediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
duration.CustomTitle,
duration.GuideMode),
ProgramScheduleItemFlood flood =>
new ProgramScheduleItemFloodViewModel(
flood.Id,
@@ -52,6 +73,9 @@ namespace ErsatzTV.Application.ProgramSchedules
flood.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.MultiCollection)
: null,
flood.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.SmartCollection)
: null,
flood.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -60,7 +84,8 @@ namespace ErsatzTV.Application.ProgramSchedules
_ => null
},
flood.PlaybackOrder,
flood.CustomTitle),
flood.CustomTitle,
flood.GuideMode),
ProgramScheduleItemMultiple multiple =>
new ProgramScheduleItemMultipleViewModel(
multiple.Id,
@@ -74,6 +99,9 @@ namespace ErsatzTV.Application.ProgramSchedules
multiple.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.MultiCollection)
: null,
multiple.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.SmartCollection)
: null,
multiple.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -83,7 +111,8 @@ namespace ErsatzTV.Application.ProgramSchedules
},
multiple.PlaybackOrder,
multiple.Count,
multiple.CustomTitle),
multiple.CustomTitle,
multiple.GuideMode),
ProgramScheduleItemOne one =>
new ProgramScheduleItemOneViewModel(
one.Id,
@@ -97,6 +126,9 @@ namespace ErsatzTV.Application.ProgramSchedules
one.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(one.MultiCollection)
: null,
one.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(one.SmartCollection)
: null,
one.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -105,7 +137,8 @@ namespace ErsatzTV.Application.ProgramSchedules
_ => null
},
one.PlaybackOrder,
one.CustomTitle),
one.CustomTitle,
one.GuideMode),
_ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
};

View File

@@ -15,11 +15,18 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
TimeSpan playoutDuration,
bool offlineTail,
string customTitle) : base(
TailMode tailMode,
ProgramScheduleItemCollectionType tailCollectionType,
MediaCollectionViewModel tailCollection,
MultiCollectionViewModel tailMultiCollection,
SmartCollectionViewModel tailSmartCollection,
NamedMediaItemViewModel tailMediaItem,
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -28,15 +35,29 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)
customTitle,
guideMode)
{
PlayoutDuration = playoutDuration;
OfflineTail = offlineTail;
TailMode = tailMode;
TailCollectionType = tailCollectionType;
TailCollection = tailCollection;
TailMultiCollection = tailMultiCollection;
TailSmartCollection = tailSmartCollection;
TailMediaItem = tailMediaItem;
}
public TimeSpan PlayoutDuration { get; }
public bool OfflineTail { get; }
public TailMode TailMode { get; }
public ProgramScheduleItemCollectionType TailCollectionType { get; }
public MediaCollectionViewModel TailCollection { get; }
public MultiCollectionViewModel TailMultiCollection { get; }
public SmartCollectionViewModel TailSmartCollection { get; }
public NamedMediaItemViewModel TailMediaItem { get; }
}
}

View File

@@ -15,9 +15,11 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -26,9 +28,11 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)
customTitle,
guideMode)
{
}
}

View File

@@ -15,10 +15,12 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
int count,
string customTitle) : base(
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -27,9 +29,11 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle) =>
customTitle,
guideMode) =>
Count = count;
public int Count { get; }

View File

@@ -15,9 +15,11 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -26,9 +28,11 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)
customTitle,
guideMode)
{
}
}

View File

@@ -14,9 +14,11 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection,
MultiCollectionViewModel MultiCollection,
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
string CustomTitle)
string CustomTitle,
GuideMode GuideMode)
{
public string Name => CollectionType switch
{
@@ -29,6 +31,8 @@ namespace ErsatzTV.Application.ProgramSchedules
MediaItem?.Name,
ProgramScheduleItemCollectionType.MultiCollection =>
MultiCollection?.Name,
ProgramScheduleItemCollectionType.SmartCollection =>
SmartCollection?.Name,
_ => string.Empty
};
}

View File

@@ -28,7 +28,12 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
.Filter(psi => psi.ProgramScheduleId == request.Id)
.Include(i => i.Collection)
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.MediaItem)
.Include(i => (i as ProgramScheduleItemDuration).TailCollection)
.Include(i => (i as ProgramScheduleItemDuration).TailMultiCollection)
.Include(i => (i as ProgramScheduleItemDuration).TailSmartCollection)
.Include(i => (i as ProgramScheduleItemDuration).TailMediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)

View File

@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
using LanguageExt;
using MediatR;
@@ -19,11 +20,13 @@ namespace ErsatzTV.Application.Search.Queries
QuerySearchIndexAllItems request,
CancellationToken cancellationToken) =>
new(
await GetIds("movie", request.Query),
await GetIds("show", request.Query),
await GetIds("episode", request.Query),
await GetIds("artist", request.Query),
await GetIds("music_video", request.Query));
await GetIds(SearchIndex.MovieType, request.Query),
await GetIds(SearchIndex.ShowType, request.Query),
await GetIds(SearchIndex.SeasonType, request.Query),
await GetIds(SearchIndex.EpisodeType, request.Query),
await GetIds(SearchIndex.ArtistType, request.Query),
await GetIds(SearchIndex.MusicVideoType, request.Query),
await GetIds(SearchIndex.OtherVideoType, request.Query));
private Task<List<int>> GetIds(string type, string query) =>
_searchIndex.Search($"type:{type} AND ({query})", 0, 0)

View File

@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexOtherVideos
(string Query, int PageNumber, int PageSize) : IRequest<OtherVideoCardResultsViewModel>;
}

View File

@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexOtherVideosHandler : IRequestHandler<QuerySearchIndexOtherVideos,
OtherVideoCardResultsViewModel>
{
private readonly IOtherVideoRepository _otherVideoRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexOtherVideosHandler(ISearchIndex searchIndex, IOtherVideoRepository otherVideoRepository)
{
_searchIndex = searchIndex;
_otherVideoRepository = otherVideoRepository;
}
public async Task<OtherVideoCardResultsViewModel> Handle(
QuerySearchIndexOtherVideos request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<OtherVideoCardViewModel> items = await _otherVideoRepository
.GetOtherVideosForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new OtherVideoCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}

View File

@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexSeasons
(string Query, int PageNumber, int PageSize) : IRequest<TelevisionSeasonCardResultsViewModel>;
}

View File

@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexSeasonsHandler : IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
private readonly ITelevisionRepository _televisionRepository;
public QuerySearchIndexSeasonsHandler(
ISearchIndex searchIndex,
ITelevisionRepository televisionRepository,
IMediaSourceRepository mediaSourceRepository)
{
_searchIndex = searchIndex;
_televisionRepository = televisionRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<TelevisionSeasonCardResultsViewModel> Handle(
QuerySearchIndexSeasons request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<TelevisionSeasonCardViewModel> items = await _televisionRepository
.GetSeasonsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
return new TelevisionSeasonCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}

View File

@@ -5,7 +5,9 @@ namespace ErsatzTV.Application.Search
public record SearchResultAllItemsViewModel(
List<int> MovieIds,
List<int> ShowIds,
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds);
List<int> MusicVideoIds,
List<int> OtherVideoIds);
}

View File

@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Streaming.Commands
{
public record StartFFmpegSession(string ChannelNumber, bool StartAtZero) :
MediatR.IRequest<Either<BaseError, Unit>>,
IFFmpegWorkerRequest;
}

View File

@@ -0,0 +1,111 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Commands
{
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly ILogger<StartFFmpegSessionHandler> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
public StartFFmpegSessionHandler(
ILocalFileSystem localFileSystem,
ILogger<StartFFmpegSessionHandler> logger,
IServiceScopeFactory serviceScopeFactory,
IFFmpegSegmenterService ffmpegSegmenterService,
IConfigElementRepository configElementRepository)
{
_localFileSystem = localFileSystem;
_logger = logger;
_serviceScopeFactory = serviceScopeFactory;
_ffmpegSegmenterService = ffmpegSegmenterService;
_configElementRepository = configElementRepository;
}
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => StartProcess(request))
// this weirdness is needed to maintain the error type (.ToEitherAsync() just gives BaseError)
#pragma warning disable VSTHRD103
.Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync<BaseError, Task<Unit>, Unit>(identity));
#pragma warning restore VSTHRD103
private async Task<Unit> StartProcess(StartFFmpegSession request)
{
TimeSpan idleTimeout = await _configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout)
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1)));
using IServiceScope scope = _serviceScopeFactory.CreateScope();
HlsSessionWorker worker = scope.ServiceProvider.GetRequiredService<HlsSessionWorker>();
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker);
// fire and forget worker
_ = worker.Run(request.ChannelNumber, idleTimeout)
.ContinueWith(
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
request.ChannelNumber,
out IHlsSessionWorker _),
TaskScheduler.Default);
string playlistFileName = Path.Combine(
FileSystemLayout.TranscodeFolder,
request.ChannelNumber,
"live.m3u8");
while (!File.Exists(playlistFileName))
{
await Task.Delay(TimeSpan.FromMilliseconds(100));
}
return Unit.Default;
}
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
SessionMustBeInactive(request)
.BindT(_ => FolderMustBeEmpty(request));
private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
{
var result = Optional(_ffmpegSegmenterService.SessionWorkers.TryAdd(request.ChannelNumber, null))
.Filter(success => success)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelSessionAlreadyActive());
if (result.IsFail && _ffmpegSegmenterService.SessionWorkers.TryGetValue(
request.ChannelNumber,
out IHlsSessionWorker worker))
{
worker?.Touch();
}
return result.AsTask();
}
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
{
string folder = Path.Combine(FileSystemLayout.TranscodeFolder, request.ChannelNumber);
_logger.LogDebug("Preparing transcode folder {Folder}", folder);
_localFileSystem.EnsureFolderExists(folder);
_localFileSystem.EmptyFolder(folder);
return Task.FromResult<Validation<BaseError, Unit>>(Unit.Default);
}
}
}

View File

@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Streaming.Commands
{
public record TouchFFmpegSession(string Path) : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
}

View File

@@ -0,0 +1,243 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming
{
public class HlsSessionWorker : IHlsSessionWorker
{
private static int _workAheadCount;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<HlsSessionWorker> _logger;
private DateTimeOffset _lastAccess;
private DateTimeOffset _transcodedUntil;
private Timer _timer;
private readonly object _sync = new();
private DateTimeOffset _playlistStart;
public HlsSessionWorker(IServiceScopeFactory serviceScopeFactory, ILogger<HlsSessionWorker> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
public DateTimeOffset PlaylistStart => _playlistStart;
public void Touch()
{
lock (_sync)
{
_lastAccess = DateTimeOffset.Now;
_timer?.Stop();
_timer?.Start();
}
}
public async Task Run(string channelNumber, TimeSpan idleTimeout)
{
var cts = new CancellationTokenSource();
void Cancel(object o, ElapsedEventArgs e) => cts.Cancel();
try
{
lock (_sync)
{
_timer = new Timer(idleTimeout.TotalMilliseconds) { AutoReset = false };
_timer.Elapsed += Cancel;
}
CancellationToken cancellationToken = cts.Token;
_logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
Touch();
_transcodedUntil = DateTimeOffset.Now;
_playlistStart = _transcodedUntil;
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
if (!await Transcode(channelNumber, true, !initialWorkAhead, cancellationToken))
{
return;
}
while (!cancellationToken.IsCancellationRequested)
{
if (DateTimeOffset.Now - _lastAccess > idleTimeout)
{
_logger.LogInformation("Stopping idle HLS session for channel {Channel}", channelNumber);
return;
}
var transcodedBuffer = TimeSpan.FromSeconds(
Math.Max(0, _transcodedUntil.Subtract(DateTimeOffset.Now).TotalSeconds));
if (transcodedBuffer <= TimeSpan.FromMinutes(1))
{
// only use realtime encoding when we're at least 30 seconds ahead
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
bool subsequentWorkAhead =
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
if (!await Transcode(channelNumber, false, !subsequentWorkAhead, cancellationToken))
{
return;
}
}
else
{
await TrimAndDelete(channelNumber, cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
}
}
}
finally
{
lock (_sync)
{
_timer.Elapsed -= Cancel;
}
}
}
private async Task<bool> Transcode(string channelNumber, bool firstProcess, bool realtime, CancellationToken cancellationToken)
{
try
{
if (!realtime)
{
Interlocked.Increment(ref _workAheadCount);
_logger.LogInformation("HLS segmenter will work ahead for channel {Channel}", channelNumber);
}
else
{
_logger.LogInformation(
"HLS segmenter will NOT work ahead for channel {Channel}",
channelNumber);
}
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var request = new GetPlayoutItemProcessByChannelNumber(
channelNumber,
"segmenter",
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1),
!firstProcess,
realtime);
// _logger.LogInformation("Request {@Request}", request);
Either<BaseError, PlayoutItemProcessModel> result = await mediator.Send(request, cancellationToken);
// _logger.LogInformation("Result {Result}", result.ToString());
foreach (BaseError error in result.LeftAsEnumerable())
{
_logger.LogWarning(
"Failed to create process for HLS session on channel {Channel}: {Error}",
channelNumber,
error.ToString());
return false;
}
foreach (PlayoutItemProcessModel processModel in result.RightAsEnumerable())
{
await TrimAndDelete(channelNumber, cancellationToken);
Process process = processModel.Process;
_logger.LogDebug(
"ffmpeg hls arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
try
{
await process.WaitForExitAsync(cancellationToken);
process.WaitForExit();
}
catch (TaskCanceledException)
{
_logger.LogInformation("Terminating HLS process for channel {Channel}", channelNumber);
process.Kill();
process.WaitForExit();
return false;
}
_logger.LogInformation("HLS process has completed for channel {Channel}", channelNumber);
_transcodedUntil = processModel.Until;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error transcoding channel {Channel}", channelNumber);
return false;
}
finally
{
Interlocked.Decrement(ref _workAheadCount);
}
return true;
}
private async Task TrimAndDelete(string channelNumber, CancellationToken cancellationToken)
{
string playlistFileName = Path.Combine(
FileSystemLayout.TranscodeFolder,
channelNumber,
"live.m3u8");
if (File.Exists(playlistFileName))
{
// trim playlist and insert discontinuity before appending with new ffmpeg process
string[] lines = await File.ReadAllLinesAsync(playlistFileName, cancellationToken);
TrimPlaylistResult trimResult = HlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
_playlistStart,
DateTimeOffset.Now.AddMinutes(-1),
lines);
await File.WriteAllTextAsync(playlistFileName, trimResult.Playlist, cancellationToken);
// delete old segments
foreach (string file in Directory.GetFiles(
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber),
"*.ts"))
{
string fileName = Path.GetFileName(file);
if (fileName.StartsWith("live") && int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]) <
trimResult.Sequence)
{
File.Delete(file);
}
}
_playlistStart = trimResult.PlaylistStart;
}
}
private async Task<int> GetWorkAheadLimit()
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IConfigElementRepository repo = scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
return await repo.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters)
.Map(maybeCount => maybeCount.Match(identity, () => 1));
}
}
}

View File

@@ -0,0 +1,7 @@
using System;
using System.Diagnostics;
namespace ErsatzTV.Application.Streaming
{
public record PlayoutItemProcessModel(Process Process, DateTimeOffset Until);
}

View File

@@ -1,5 +1,4 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -14,7 +13,7 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Queries
{
public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseError, Process>>
public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseError, PlayoutItemProcessModel>>
where T : FFmpegProcessRequest
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -22,16 +21,16 @@ namespace ErsatzTV.Application.Streaming.Queries
protected FFmpegProcessHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Process>> Handle(T request, CancellationToken cancellationToken)
public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2),
error => Task.FromResult<Either<BaseError, Process>>(error.Join()));
error => Task.FromResult<Either<BaseError, PlayoutItemProcessModel>>(error.Join()));
}
protected abstract Task<Either<BaseError, Process>> GetProcess(
protected abstract Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
T request,
Channel channel,
@@ -56,6 +55,7 @@ namespace ErsatzTV.Application.Streaming.Queries
channel.StreamingMode = request.Mode.ToLowerInvariant() switch
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};

View File

@@ -1,9 +1,15 @@
using System.Diagnostics;
using System;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
public record FFmpegProcessRequest(string ChannelNumber, string Mode) : IRequest<Either<BaseError, Process>>;
public record FFmpegProcessRequest
(
string ChannelNumber,
string Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
}

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