Compare commits

...
93 Commits
Author SHA1 Message Date
Jason Dove 0440f7643b add videotoolbox acceleration (#575) 2022-01-17 15:05:23 -06:00
Jason Dove 0f4219f731 properly unlock libraries after failed scans (#574) 2022-01-14 13:03:15 -06:00
Jason Dove cbe5d47611 fix trakt list sync when show does not contain a year (#572) 2022-01-12 21:09:26 -06:00
Jason Dove afa52ccc89 add trash system for local libraries (#571)
* flag local movies as file not found

* show warning icon on cards

* unflag movie that is found during scan

* skip missing files when building playouts

* add state to search index

* add file not found health check

* link to search from file not found health check

* support flagging other media kinds as file not found

* continue to schedule missing items

* support episode files not found

* wip trash page

* fix trash url

* trash page is functional

* update changelog

* fix changelog merge
2022-01-12 20:27:53 -06:00
Jason Dove 7d1163c68f fix double-click startup on mac (#570) 2022-01-11 15:36:12 -06:00
Jason Dove 883492bd33 update changelog for release v0.3.6-alpha [no ci] 2022-01-10 19:51:01 -06:00
Jason Dove a4eac4feea properly overwrite environment variables (#567) 2022-01-08 10:01:22 -06:00
Jason Dove dab58f5840 fix tests 2022-01-07 19:18:31 -06:00
Jason Dove 176f136c23 fix some nvenc edge cases where only padding is needed for normalization (#565) 2022-01-07 18:53:28 -06:00
Jason Dove 816d77e15b update changelog [no ci] 2022-01-06 12:01:25 -06:00
Jason Dove 7c4d47a211 update changelog [no ci] 2022-01-06 10:31:18 -06:00
Jason Dove d9d2cfa8be search index fixes (#559)
* add music video artist to search index

* properly index minutes field when adding from scan

* bump search index version
2022-01-06 10:28:53 -06:00
Jason Dove 8036e46966 update streaming mode docs (#558) [no docker] 2022-01-06 09:10:12 -06:00
Jason Dove 594ce437fb rework mpeg-ts mode (#557) 2022-01-05 21:27:28 -06:00
Jason Dove 004c43f895 update changelog for release v0.3.5-alpha [no ci] 2022-01-05 09:26:58 -06:00
Jason Dove 257384ea9b fix health checks (#556)
* update bundled ffmpeg version in health check

* recognize qsv acceleration on linux

* update changelog
2022-01-05 08:29:52 -06:00
Jason Dove 637f3a0c8b update docs [no docker] 2022-01-04 22:38:52 -06:00
Jason Dove 7346808059 update dependencies (#555) 2022-01-04 22:35:14 -06:00
Jason Dove 4210d97ee2 optimize setsar filter (#553) 2022-01-02 23:47:07 -06:00
Jason Dove 6a8ecd2532 use software decoding for mpeg4 with vaapi (#550) 2022-01-02 10:59:08 -06:00
Jason Dove 9b834f7cbe update changelog for release v0.3.4-alpha [no ci] 2021-12-21 09:46:43 -06:00
Jason Dove 7b73677bad allow ffmpeg reports on windows (#547)
* enable troubleshooting reports on windows

* update changelog

* tweak changelog
2021-12-21 09:27:49 -06:00
Jason Dove 85b2a46353 update dependencies (#546) 2021-12-21 08:52:51 -06:00
Jason Dove 6f40f2cbd6 fix songs docs [no docker] 2021-12-17 08:48:40 -06:00
Jason Dove b62ee4dee9 add files from top-level folder (#541) 2021-12-14 14:27:12 -06:00
Jason Dove a6e7f192cc add jellyfin path replacement tests [no ci] 2021-12-13 06:25:37 -06:00
Jason Dove 59a1a4a8dc update changelog for release v0.3.3-alpha [no ci] 2021-12-12 23:53:12 -06:00
Jason Dove 85a9afb51c update dependencies (#538) 2021-12-12 23:51:57 -06:00
Jason Dove 246b4d7591 properly sort channels in m3u (#537) 2021-12-10 20:22:52 -06:00
Jason Dove ae2c6350e1 sync virtual shows and season from jellyfin (#536) 2021-12-10 14:41:47 -06:00
Jason Dove ce228604e8 use select controls instead of autocomplete (#532)
* use select instead of autocomplete for playout editor

* use select instead of autocomplete for filler preset editor

* reset selected collection when changing collection type

* use select instead of autocomplete for multi collection editor

* more select

* more select controls
2021-12-06 12:49:48 -06:00
Jason Dove 3656e932d3 more song fixes (#529)
* use blurhash for default etv song backgrounds

* fix saving artwork blurhash

* fix song detail alignment

* rename song background files

* watermark path is always none here
2021-12-04 13:30:25 -06:00
Jason Dove 73887706ed update changelog for release v0.3.2-alpha [no ci] 2021-12-03 14:57:19 -06:00
Jason Dove abc103308b optimize song artwork scanning (#527) 2021-12-03 13:40:55 -06:00
Jason Dove 3773bbec19 use blurhash for song backgrounds (#526)
* generate blurhash for all local artwork

* use blurhash song background if available

* only write blur hash to disk once

* use multiple blur hashes

* update changelog

* fix song detail outline

* reset song metadata (artwork)
2021-12-03 12:30:47 -06:00
Jason Dove e223d6a43f remove unused cli project (#525) [no ci] 2021-12-02 09:01:47 -06:00
Jason Dove 8369111e31 update dependencies (#524) 2021-12-02 08:45:43 -06:00
Jason Dove 35ba2bab2c fix unicode song metadata on windows (#523)
* test setting utf8 encoding with ffprobe

* use utf8 encoding for console (logging) output

* use proper sink package

* reset song metadata on windows

* fix nfo processing with missing year

* update changelog
2021-12-01 13:43:09 -06:00
Jason Dove 094ed71ad0 fix docker builds with custom (local) nuget package (#520) 2021-11-30 20:49:02 -06:00
Jason Dove 89e24b2b78 use custom log database backend that is more portable (#519) 2021-11-30 20:34:14 -06:00
Jason Dove 848795af32 fix artwork upload on windows (#518)
* update changelog for release v0.3.1-alpha [no ci]

* fix artwork upload on windows
2021-11-30 12:23:24 -06:00
Jason Dove 56f94f489a fix filler playout crash (#517) 2021-11-30 10:38:42 -06:00
Jason Dove 475dc7660b fix artwork uploads (#516) 2021-11-29 14:34:18 -06:00
Jason Dove db3dfbd446 disambiguate song search results (#515) 2021-11-27 21:37:55 -06:00
Jason Dove b4c9cdbbfa use embedded song cover art (#514) 2021-11-27 21:08:18 -06:00
Jason Dove 7f84933c0b index song genres (#513)
* add song genres to search index

* reset all song genre metadata

* update changelog and docs
2021-11-27 18:08:55 -06:00
Jason Dove 1e35e9a5b0 use subtitles to display errors (#512)
* use subtitles to display errors

* fix margin calculation
2021-11-27 12:25:30 -06:00
Jason Dove 7edf6f5d13 song cleanup (#511)
* refactor song background logic

* move song video generation

* move subtitle generation

* build ASS subtitles

* randomize song detail layout

* update changelog
2021-11-27 11:15:53 -06:00
Jason Dove 919325033d use subtitles instead of drawtext for songs (#510) 2021-11-26 21:39:10 -06:00
Jason Dove 2cb5252320 fix song banding (#509)
* increase spacing in song details; uniformly darken to eliminate banding

* this isn't needed anymore
2021-11-26 15:20:41 -06:00
Jason Dove 015232fad6 song improvements (#508)
* fix song details margin and use dynamic font size

* sometimes use cover art color for song background
2021-11-26 13:23:28 -06:00
Jason Dove af51b790b6 randomize cover art placement (#507) 2021-11-26 09:21:00 -06:00
Jason Dove 9195ef7878 song fixes (#506)
* fix song page links

* show song artist in playout detail

* show more song details in channel guide
2021-11-26 08:49:04 -06:00
Jason Dove dfc4c7a284 update changelog for release v0.3.0-alpha [no ci] 2021-11-25 20:49:23 -06:00
Jason Dove a6b15f68c9 randomize default backgrounds (#504)
* randomize default song backgrounds

* update docs
2021-11-25 20:19:26 -06:00
Jason Dove 0edfb71f8d limit disk use and keep cover art aspect ratio (#502)
* use temp file pool to limit disk use

* keep aspect ratio and crop when scaling cover art for blurred background

* fix typo
2021-11-25 18:47:22 -06:00
Jason Dove 21b90a1b6c fix songs with white backgrounds (#501) 2021-11-25 15:36:40 -06:00
Jason Dove 1582f5dd15 update changelog [no ci] 2021-11-25 13:37:33 -06:00
Jason Dove fd3b72525d fix vaapi songs (#500) 2021-11-25 13:35:57 -06:00
Jason Dove 55d1871d94 re-enable hardware acceleration for songs (#499) 2021-11-25 13:04:13 -06:00
Jason Dove a90eb2d4de optimize generated video (#498)
* use different framerate flags

* pre-generate song image and always use software encoders

* fix tests
2021-11-25 12:31:57 -06:00
Jason Dove ed3f1b1dad generate song video (#497)
* use blurred cover art as song background

* use channel watermark when cover art is unavailable

* add drawtext to song filter

* cleanup

* force song cover art as png

* fix songs on windows and qsv
2021-11-25 06:22:38 -06:00
Jason Dove 8e08ff059f load embedded song metadata (#495)
* load embedded song metadata

* index song artist and song album

* reset all song metadata
2021-11-24 07:31:34 -06:00
Jason Dove fb8c3a0453 disable autoscale when looping with vaapi or qsv (#494) 2021-11-23 13:25:23 -06:00
Jason Dove e45fb67769 bug fixes (#493)
* don't align audio when playing songs

* fix grouping duration items in epg
2021-11-23 11:44:39 -06:00
Jason Dove 3a40d6ce77 fix local library locking when adding paths (#492) 2021-11-23 10:54:34 -06:00
Jason Dove ac048b72ae add cover art watermark source (#491)
* add cover art watermark source

* update changelog
2021-11-23 10:02:36 -06:00
Jason Dove 852728c816 add songs libraries (#490)
* first pass at adding song libraries

* start handling optional video

* fix song playback

* fix song transitions

* add songs page to UI
2021-11-22 22:26:06 -06:00
Jason Dove 096f2d42e8 properly fix database upgrade (#489) 2021-11-22 17:56:29 -06:00
Jason Dove 1b29e252ff update changelog for release v0.2.5-alpha [no ci] 2021-11-21 07:24:20 -06:00
Jason Dove a4dc9bfb31 Ignore local plex guids (#488)
* ignore local plex guids

* update dependencies
2021-11-21 06:25:56 -06:00
Jason Dove 184c21a91b optimize trakt matching (#487) 2021-11-21 06:13:28 -06:00
Jason Dove 6ea3191cf8 fix playout building (#486) 2021-11-20 22:36:15 -06:00
Jason Dove d487bbca08 include other video title in channel guide (#483) 2021-11-16 08:46:07 -06:00
Jason Dove 05034b47e2 update changelog for release v0.2.4-alpha [no ci] 2021-11-13 12:54:41 -06:00
Jason Dove b0c85b6478 use scale_cuda instead of scale_npp (#481) 2021-11-13 09:06:02 -06:00
Jason Dove f1356563da fix ef shared table warnings (#480) 2021-11-10 18:03:28 -06:00
Jason Dove c0aad028a8 more dotnet 6 updates (#479) 2021-11-09 13:09:57 -06:00
Jason Dove dae06ec0ef upgrade to dotnet 6 (#475) 2021-11-09 07:44:34 -06:00
Jason Dove 72f452fd36 update dependencies (#474) 2021-11-09 06:16:26 -06:00
Jason Dove aaf832c0b6 update changelog for release v0.2.3-alpha [no ci] 2021-11-03 13:53:06 -05:00
Jason Dove 08a18daf23 movie scanner should respect .etvignore files (#468) 2021-11-03 05:47:29 -05:00
Jason Dove 90c1c61a09 fix bug with flood playout mode (#467) 2021-11-02 21:47:46 -05:00
Jason Dove 053db71d44 fix decimal separator in ffmpeg apad filter syntax (#464) 2021-11-01 22:18:06 -05:00
Jason Dove 11f90f5d44 update changelog for release v0.2.2-alpha [no ci] 2021-10-30 17:49:30 -05:00
Jason Dove bda4117655 allow per-episode folders in local show libraries (#462)
* allow per-episode folders in local show libraries

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

* play random item from configured channel/global fallback filler as needed
2021-10-30 11:45:40 -05:00
Jason Dove 2ea0e64ac1 fix duration schedule item epg (#455) 2021-10-24 22:00:21 -05:00
Jason Dove 5993f23ec5 update changelog for release v0.2.1-alpha [no ci] 2021-10-24 19:46:33 -05:00
Jason Dove 417f35a834 fix saving dynamic start time (#453) 2021-10-24 18:18:22 -05:00
287 changed files with 72839 additions and 2375 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"jetbrains.resharper.globaltools": { "jetbrains.resharper.globaltools": {
"version": "2021.1.3", "version": "2021.2.2",
"commands": [ "commands": [
"jb" "jb"
] ]
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: Setup .NET Core - name: Setup .NET Core
uses: actions/setup-dotnet@v1 uses: actions/setup-dotnet@v1
with: with:
dotnet-version: 5.0.x dotnet-version: 6.0.x
- name: Clean - name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear run: dotnet clean --configuration Release && dotnet nuget locals all --clear
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout master - name: Checkout master
uses: actions/checkout@v1 uses: actions/checkout@v2
- name: Deploy docs - name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master uses: mhausenblas/mkdocs-deploy-gh-pages@master
+6 -6
View File
@@ -18,8 +18,11 @@ jobs:
kind: windows kind: windows
target: win-x64 target: win-x64
- os: macos-latest - os: macos-latest
kind: maxOS kind: macOS
target: osx-x64 target: osx-x64
- os: macos-latest
kind: macOS
target: osx-arm64
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Get the sources - name: Get the sources
@@ -28,7 +31,7 @@ jobs:
- name: Setup .NET Core - name: Setup .NET Core
uses: actions/setup-dotnet@v1 uses: actions/setup-dotnet@v1
with: with:
dotnet-version: 5.0.x dotnet-version: 6.0.x
- name: Clean - name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -44,14 +47,11 @@ jobs:
release_name="ErsatzTV-$tag-${{ matrix.target }}" release_name="ErsatzTV-$tag-${{ matrix.target }}"
# Build everything # Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:PublishSingleFile=true --self-contained true dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:EnableCompressionInSingleFile=true /property:DebugType=Embedded /property:PublishSingleFile=true --self-contained true
# Pack files # Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then if [ "${{ matrix.target }}" == "win-x64" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*" 7z a -tzip "${release_name}.zip" "./${release_name}/*"
elif [ "${{ matrix.target }}" == "linux-arm" ]; then
cp lib/linux-arm/* "$release_name/"
tar czvf "${release_name}.tar.gz" "$release_name"
else else
tar czvf "${release_name}.tar.gz" "$release_name" tar czvf "${release_name}.tar.gz" "$release_name"
fi fi
+166 -1
View File
@@ -4,6 +4,159 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- Fix local folder scanners to properly detect removed/re-added folders with unchanged contents
- Fix double-click startup on mac
- Fix trakt list sync when show does not contain a year
- Properly unlock libraries when a scan is unable to be performed because ffmpeg or ffprobe have not been found
### Added
- Add trash system for local libraries to maintain collection and schedule integrity through media share outages
- When items are missing from disk, they will be flagged and present in the `Media` > `Trash` page
- The trash page can be used to permanently remove missing items from the database
- When items reappear at the expected location on disk, they will be unflagged and removed from the trash
- Add basic Mac hardware acceleration using VideoToolbox
### Changed
- Local libraries only: when items are missing from disk, they will be added to the trash and no longer removed from collections, etc.
- Show song thumbnail in song list
## [0.3.6-alpha] - 2022-01-10
### Fixed
- Properly index `minutes` field when adding new items during scan (vs when rebuilding index)
- Fix some nvenc edge cases where only padding is needed for normalization
- Properly overwrite environment variables for ffmpeg processes (`LIBVA_DRIVER_NAME`, `FFREPORT`)
### Added
- Add music video `artist` to search index
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Changed
- Remove `HLS Hybrid` streaming mode; all channels have been reconfigured to use the superior `HLS Segmenter` streaming mode
- Update `MPEG-TS` streaming mode to internally use the HLS segmenter
- This improves compatibility with many clients and also improves performance at program boundaries
- Renamed existing `MPEG-TS` mode as `MPEG-TS (Legacy)`
- This mode will be removed in a future release
## [0.3.5-alpha] - 2022-01-05
### Fixed
- Fix bundled ffmpeg version in base docker image (NOT nvidia or vaapi) which prevented playback since v0.3.0-alpha
- Use software decoding for mpeg4 content when VAAPI acceleration is enabled
- Fix hardware acceleration health check to recognize QSV on non-Windows platforms
### Changed
- Treat `setsar` as a hardware filter, avoiding unneeded `hwdownload` and `hwupload` steps when padding isn't required
## [0.3.4-alpha] - 2021-12-21
### Fixed
- Fix other video and song scanners to include videos contained directly in top-level folders that are added to a library
- Allow saving ffmpeg troubleshooting reports on Windows
## [0.3.3-alpha] - 2021-12-12
### Fixed
- Fix bug with saving multiple blurhash versions for cover art; all cover art will be automatically rescanned
- Fix song detail margin when no cover art exists and no watermark exists
- Fix synchronizing virtual shows and seasons from Jellyfin
- Properly sort channels in M3U
### Changed
- Use blurhash of ErsatzTV colors instead of solid colors for default song backgrounds
- Use select control instead of autocomplete control in many places
- The autocomplete control is not intuitive to use and has focus bugs
## [0.3.2-alpha] - 2021-12-03
### Fixed
- Fix artwork upload on Windows
- Fix unicode song metadata on Windows
- Fix unicode console output on Windows
- Fix TV Show NFO metadata processing when `year` is missing
- Fix song detail outline to help legibility on white backgrounds
- Optimize song artwork scanning to prevent re-processing album artwork for each song
### Changed
- Use custom log database backend which should be more portable (i.e. work in osx-arm64)
- Use cover art blurhashes for song backgrounds instead of solid colors or box blur
## [0.3.1-alpha] - 2021-11-30
### Fixed
- Fix song page links in UI
- Show song artist in playout detail
- Include song artist and cover art in channel guide (xmltv)
- Use subtitles to display errors, which fixes many edge cases of unescaped characters
- Properly split song genre tags
- Properly display all songs that have an identical album and title
- Fix channel logo and watermark uploads
- Fix regression introduced with `v0.2.4-alpha` that caused some filler edge cases to crash the playout builder
### Added
- Add song genres to search index
- Use embedded song cover art when sidecar cover art is unavailable
### Changed
- Randomly place song cover art on left or right side of screen
- Randomly use a solid color from the cover art instead of blurred cover art for song background
- Randomly select song detail layout (large title/small artist or small artist/title/album)
## [0.3.0-alpha] - 2021-11-25
### Fixed
- Properly fix database incompatibility introduced with `v0.2.4-alpha` and partially fixed with `v0.2.5-alpha`
- The proper fix requires rebuilding all playouts, which will happen on startup after upgrading
- Fix local library locking/progress display when adding paths
- Fix grouping duration items in EPG when custom title is configured
### Added
- Add *experimental* `Songs` local libraries
- Like `Other Videos`, `Songs` require no metadata or particular folder layout, and will have tags added for each containing folder
- For Example, a song at `rock/band/1990 - Album/01 whatever.flac` will have the tags `rock`, `band` and `1990 - Album`, and the title `01 whatever`
- Songs will also have basic metadata read from embedded tags (album, artist, title)
- Video will be automatically generated for songs using metadata and cover art or watermarks if available
- Add support for `.webm` video files
## [0.2.5-alpha] - 2021-11-21
### Fixed
- Include other video title in channel guide (xmltv)
- Fix bug introduced with 0.2.4-alpha that caused some playouts to build from year 0
- Use less memory matching Trakt list items
### Added
- Build osx-arm64 packages on release
### Changed
- No longer warn about local Plex guids; they aren't used for Trakt matching and can be ignored
## [0.2.4-alpha] - 2021-11-13
### Changed
- Upgrade to dotnet 6
- Use `scale_cuda` instead of `scale_npp` for NVIDIA scaling in all cases
## [0.2.3-alpha] - 2021-11-03
### Fixed
- Fix bug with audio filter in cultures where `.` is a group/thousands separator
- Fix bug where flood playout mode would only schedule one item
- This would happen if the flood was followed by another flood with a fixed start time
### Added
- Support empty `.etvignore` file to instruct local movie scanner to ignore the containing folder
## [0.2.2-alpha] - 2021-10-30
### Fixed
- Fix EPG entries for Duration schedule items that play multiple items
- Fix EPG entries for Multiple schedule items that play more than one item
### Added
- Add fallback filler settings to Channel and global FFmpeg Settings
- When streaming is attempted during an unscheduled gap, the resulting video will be determined using the following priority:
- Channel fallback filler
- Global fallback filler
- Generated `Channel Is Offline` error message video
### Changed
- Allow per-episode folders for local show libraries
- e.g. `Show Name\Season #\Episode #\Show Name - s#e#.mkv`
## [0.2.1-alpha] - 2021-10-24
### Fixed
- Fix saving dynamic start time on schedule items
## [0.2.0-alpha] - 2021-10-23 ## [0.2.0-alpha] - 2021-10-23
### Fixed ### Fixed
@@ -756,7 +909,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker. - Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.0-alpha...HEAD [Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.6-alpha...HEAD
[0.3.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.5-alpha...v0.3.6-alpha
[0.3.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.4-alpha...v0.3.5-alpha
[0.3.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.3-alpha...v0.3.4-alpha
[0.3.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.2-alpha...v0.3.3-alpha
[0.3.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.1-alpha...v0.3.2-alpha
[0.3.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...v0.3.1-alpha
[0.3.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.5-alpha...v0.3.0-alpha
[0.2.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.4-alpha...v0.2.5-alpha
[0.2.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.3-alpha...v0.2.4-alpha
[0.2.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.2-alpha...v0.2.3-alpha
[0.2.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.1-alpha...v0.2.2-alpha
[0.2.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.0-alpha...v0.2.1-alpha
[0.2.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.5-alpha...v0.2.0-alpha [0.2.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.5-alpha...v0.2.0-alpha
[0.1.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.4-alpha...v0.1.5-alpha [0.1.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.4-alpha...v0.1.5-alpha
[0.1.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.3-alpha...v0.1.4-alpha [0.1.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.3-alpha...v0.1.4-alpha
@@ -10,5 +10,7 @@ namespace ErsatzTV.Application.Channels
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId); int? WatermarkId,
int? FallbackFillerId,
int PlayoutCount);
} }
@@ -13,5 +13,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId) : IRequest<Either<BaseError, CreateChannelResult>>; int? WatermarkId,
int? FallbackFillerId) : IRequest<Either<BaseError, CreateChannelResult>>;
} }
@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
@@ -42,9 +43,10 @@ namespace ErsatzTV.Application.Channels.Commands
(ValidateName(request), await ValidateNumber(dbContext, request), (ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request), await FFmpegProfileMustExist(dbContext, request),
ValidatePreferredLanguage(request), ValidatePreferredLanguage(request),
await WatermarkMustExist(dbContext, request)) await WatermarkMustExist(dbContext, request),
await FillerPresetMustExist(dbContext, request))
.Apply( .Apply(
(name, number, ffmpegProfileId, preferredLanguageCode, watermarkId) => (name, number, ffmpegProfileId, preferredLanguageCode, watermarkId, fillerPresetId) =>
{ {
var artwork = new List<Artwork>(); var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo)) if (!string.IsNullOrWhiteSpace(request.Logo))
@@ -74,6 +76,11 @@ namespace ErsatzTV.Application.Channels.Commands
channel.WatermarkId = id; channel.WatermarkId = id;
} }
foreach (int id in fillerPresetId)
{
channel.FallbackFillerId = id;
}
return channel; return channel;
}); });
@@ -131,5 +138,25 @@ namespace ErsatzTV.Application.Channels.Commands
.MapT(_ => Optional(createChannel.WatermarkId)) .MapT(_ => Optional(createChannel.WatermarkId))
.Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist.")); .Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist."));
} }
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
TvContext dbContext,
CreateChannel createChannel)
{
if (createChannel.FallbackFillerId is null)
{
return Option<int>.None;
}
return await dbContext.FillerPresets
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
.CountAsync(w => w.Id == createChannel.FallbackFillerId)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.FallbackFillerId))
.Map(
o => o.ToValidation<BaseError>(
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
}
} }
} }
@@ -14,5 +14,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId) : IRequest<Either<BaseError, ChannelViewModel>>; int? WatermarkId,
int? FallbackFillerId) : IRequest<Either<BaseError, ChannelViewModel>>;
} }
@@ -67,6 +67,7 @@ namespace ErsatzTV.Application.Channels.Commands
c.StreamingMode = update.StreamingMode; c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId; c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return ProjectToViewModel(c); return ProjectToViewModel(c);
} }
+3 -1
View File
@@ -15,7 +15,9 @@ namespace ErsatzTV.Application.Channels
GetLogo(channel), GetLogo(channel),
channel.PreferredLanguageCode, channel.PreferredLanguageCode,
channel.StreamingMode, channel.StreamingMode,
channel.WatermarkId); channel.WatermarkId,
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0);
private static string GetLogo(Channel channel) => private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo)) Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
@@ -25,7 +25,7 @@ namespace ErsatzTV.Application.Configuration.Commands
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) => private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
Optional(request.LibraryRefreshInterval) Optional(request.LibraryRefreshInterval)
.Filter(lri => lri > 0) .Where(lri => lri > 0)
.Map(_ => Unit.Default) .Map(_ => Unit.Default)
.ToValidation<BaseError>("Tuner count must be greater than zero") .ToValidation<BaseError>("Tuner count must be greater than zero")
.AsTask(); .AsTask();
@@ -58,7 +58,7 @@ namespace ErsatzTV.Application.Configuration.Commands
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) => private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) =>
Optional(request.DaysToBuild) Optional(request.DaysToBuild)
.Filter(days => days > 0) .Where(days => days > 0)
.Map(_ => Unit.Default) .Map(_ => Unit.Default)
.ToValidation<BaseError>("Days to build must be greater than zero") .ToValidation<BaseError>("Days to build must be greater than zero")
.AsTask(); .AsTask();
@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Emby.Commands
{ {
EmbySecrets secrets = await _embySecretStore.ReadSecrets(); EmbySecrets secrets = await _embySecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Filter(match => match) .Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Emby media source requires an api key"); .ToValidation<BaseError>("Emby media source requires an api key");
} }
@@ -142,7 +142,7 @@ namespace ErsatzTV.Application.Emby.Commands
{ {
EmbySecrets secrets = await _embySecretStore.ReadSecrets(); EmbySecrets secrets = await _embySecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Filter(match => match) .Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Emby media source requires an api key"); .ToValidation<BaseError>("Emby media source requires an api key");
} }
@@ -1,15 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MediatR" Version="9.0.0" /> <PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -1,13 +1,11 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Application.FFmpegProfiles.Commands namespace ErsatzTV.Application.FFmpegProfiles.Commands
@@ -16,16 +14,13 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly IRuntimeInfo _runtimeInfo;
public UpdateFFmpegSettingsHandler( public UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem)
IRuntimeInfo runtimeInfo)
{ {
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_runtimeInfo = runtimeInfo;
} }
public Task<Either<BaseError, Unit>> Handle( public Task<Either<BaseError, Unit>> Handle(
@@ -36,8 +31,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(v => v.ToEitherAsync()); .Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) => private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
(await FFmpegMustExist(request), await FFprobeMustExist(request), ReportsAreNotSupportedOnWindows(request)) (await FFmpegMustExist(request), await FFprobeMustExist(request))
.Apply((_, _, _) => Unit.Default); .Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) => private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg"); ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
@@ -45,16 +40,6 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) => private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFprobePath, "ffprobe"); ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
private Validation<BaseError, Unit> ReportsAreNotSupportedOnWindows(UpdateFFmpegSettings request)
{
if (request.Settings.SaveReports && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
{
return BaseError.New("FFmpeg reports are not supported on Windows");
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name) private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
{ {
if (!_localFileSystem.FileExists(path)) if (!_localFileSystem.FileExists(path))
@@ -115,6 +100,17 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId); await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
} }
if (request.Settings.GlobalFallbackFillerId is not null)
{
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegGlobalFallbackFillerId,
request.Settings.GlobalFallbackFillerId.Value);
}
else
{
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId);
}
await _configElementRepository.Upsert( await _configElementRepository.Upsert(
ConfigElementKey.FFmpegSegmenterTimeout, ConfigElementKey.FFmpegSegmenterTimeout,
request.Settings.HlsSegmenterIdleTimeout); request.Settings.HlsSegmenterIdleTimeout);
@@ -8,6 +8,7 @@
public string PreferredLanguageCode { get; set; } public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; } public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; } public int? GlobalWatermarkId { get; set; }
public int? GlobalFallbackFillerId { get; set; }
public int HlsSegmenterIdleTimeout { get; set; } public int HlsSegmenterIdleTimeout { get; set; }
public int WorkAheadSegmenterLimit { get; set; } public int WorkAheadSegmenterLimit { get; set; }
} }
@@ -28,6 +28,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode); await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<int> watermark = Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId); await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
Option<int> fallbackFiller =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId);
Option<int> hlsSegmenterIdleTimeout = Option<int> hlsSegmenterIdleTimeout =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout); await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout);
Option<int> workAheadSegmenterLimit = Option<int> workAheadSegmenterLimit =
@@ -49,6 +51,11 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
result.GlobalWatermarkId = watermarkId; result.GlobalWatermarkId = watermarkId;
} }
foreach (int fallbackFillerId in fallbackFiller)
{
result.GlobalFallbackFillerId = fallbackFillerId;
}
return result; return result;
} }
} }
@@ -24,7 +24,7 @@ namespace ErsatzTV.Application.HDHR.Commands
private static Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) => private static Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>
Optional(request.TunerCount) Optional(request.TunerCount)
.Filter(tc => tc > 0) .Where(tc => tc > 0)
.Map(_ => Unit.Default) .Map(_ => Unit.Default)
.ToValidation<BaseError>("Tuner count must be greater than zero") .ToValidation<BaseError>("Tuner count must be greater than zero")
.AsTask(); .AsTask();
@@ -1,4 +1,5 @@
using ErsatzTV.Core; using System.IO;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
@@ -6,5 +7,5 @@ using MediatR;
namespace ErsatzTV.Application.Images.Commands namespace ErsatzTV.Application.Images.Commands
{ {
// ReSharper disable once SuggestBaseTypeForParameter // ReSharper disable once SuggestBaseTypeForParameter
public record SaveArtworkToDisk(byte[] Buffer, ArtworkKind ArtworkKind) : IRequest<Either<BaseError, string>>; public record SaveArtworkToDisk(Stream Stream, ArtworkKind ArtworkKind) : IRequest<Either<BaseError, string>>;
} }
@@ -14,6 +14,6 @@ namespace ErsatzTV.Application.Images.Commands
public SaveArtworkToDiskHandler(IImageCache imageCache) => _imageCache = imageCache; public SaveArtworkToDiskHandler(IImageCache imageCache) => _imageCache = imageCache;
public Task<Either<BaseError, string>> Handle(SaveArtworkToDisk request, CancellationToken cancellationToken) => public Task<Either<BaseError, string>> Handle(SaveArtworkToDisk request, CancellationToken cancellationToken) =>
_imageCache.SaveArtworkToCache(request.Buffer, request.ArtworkKind); _imageCache.SaveArtworkToCache(request.Stream, request.ArtworkKind);
} }
} }
@@ -97,7 +97,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
{ {
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets(); JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Filter(match => match) .Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Jellyfin media source requires an api key"); .ToValidation<BaseError>("Jellyfin media source requires an api key");
} }
@@ -69,7 +69,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
{ {
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets(); JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Filter(match => match) .Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Jellyfin media source requires an api key"); .ToValidation<BaseError>("Jellyfin media source requires an api key");
} }
@@ -142,7 +142,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
{ {
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets(); JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Filter(match => match) .Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Jellyfin media source requires an api key"); .ToValidation<BaseError>("Jellyfin media source requires an api key");
} }
@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Linq;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Channels; using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -37,7 +36,7 @@ namespace ErsatzTV.Application.Libraries.Commands
CreateLocalLibrary request, CreateLocalLibrary request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request); Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary)); return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
} }
@@ -46,7 +46,7 @@ namespace ErsatzTV.Application.Libraries.Commands
.Map(list => list.Map(c => c.Path).ToList()); .Map(list => list.Map(c => c.Path).ToList());
return Optional(request.Path) return Optional(request.Path)
.Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder))) .Where(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Path must not belong to another library path"); .ToValidation<BaseError>("Path must not belong to another library path");
} }
@@ -33,7 +33,7 @@ namespace ErsatzTV.Application.Libraries.Commands
.Map(list => list.SelectMany(ll => ll.Paths).Map(lp => lp.Path).ToList()); .Map(list => list.SelectMany(ll => ll.Paths).Map(lp => lp.Path).ToList());
return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path)))) return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path))))
.Filter(length => length == 0) .Where(length => length == 0)
.Map(_ => localLibrary) .Map(_ => localLibrary)
.ToValidation<BaseError>("Path must not belong to another library path"); .ToValidation<BaseError>("Path must not belong to another library path");
} }
@@ -43,7 +43,7 @@ namespace ErsatzTV.Application.Libraries.Commands
UpdateLocalLibrary request, UpdateLocalLibrary request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters)); return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
} }
@@ -53,7 +53,6 @@ namespace ErsatzTV.Application.Libraries.Commands
(LocalLibrary existing, LocalLibrary incoming) = parameters; (LocalLibrary existing, LocalLibrary incoming) = parameters;
existing.Name = incoming.Name; existing.Name = incoming.Name;
// toAdd
var toAdd = incoming.Paths var toAdd = incoming.Paths
.Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path))) .Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path)))
.ToList(); .ToList();
@@ -77,7 +76,7 @@ namespace ErsatzTV.Application.Libraries.Commands
_searchIndex.Commit(); _searchIndex.Commit();
} }
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id)) if ((toAdd.Count > 0 || toRemove.Count > 0) && _entityLocker.LockLibrary(existing.Id))
{ {
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id)); await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
} }
@@ -0,0 +1,9 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Maintenance.Commands;
public record DeleteItemsFromDatabase(List<int> MediaItemIds) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,38 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Maintenance.Commands
{
public class
DeleteItemsFromDatabaseHandler : MediatR.IRequestHandler<DeleteItemsFromDatabase, Either<BaseError, Unit>>
{
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISearchIndex _searchIndex;
public DeleteItemsFromDatabaseHandler(
IMediaItemRepository mediaItemRepository,
ISearchIndex searchIndex)
{
_mediaItemRepository = mediaItemRepository;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteItemsFromDatabase request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> deleteResult = await _mediaItemRepository.DeleteItems(request.MediaItemIds);
if (deleteResult.IsRight)
{
await _searchIndex.RemoveItems(request.MediaItemIds);
_searchIndex.Commit();
}
return deleteResult;
}
}
}
@@ -1,5 +1,7 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb) : public record ActorCardViewModel(int Id, string Name, string Role, string Thumb, MediaItemState State) :
MediaCardViewModel(Id, Name, Role, Name, Thumb); MediaCardViewModel(Id, Name, Role, Name, Thumb, State);
} }
@@ -1,10 +1,19 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record ArtistCardViewModel public record ArtistCardViewModel
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel( (
ArtistId, int ArtistId,
Title, string Title,
Subtitle, string Subtitle,
SortTitle, string SortTitle,
Poster); string Poster,
MediaItemState State) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster,
State);
} }
@@ -10,7 +10,8 @@ namespace ErsatzTV.Application.MediaCards
List<TelevisionEpisodeCardViewModel> EpisodeCards, List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards, List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards, List<MusicVideoCardViewModel> MusicVideoCards,
List<OtherVideoCardViewModel> OtherVideoCards) List<OtherVideoCardViewModel> OtherVideoCards,
List<SongCardViewModel> SongCards)
{ {
public bool UseCustomPlaybackOrder { get; set; } public bool UseCustomPlaybackOrder { get; set; }
} }
+35 -10
View File
@@ -2,6 +2,7 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using LanguageExt; using LanguageExt;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@@ -19,7 +20,8 @@ namespace ErsatzTV.Application.MediaCards
showMetadata.Title, showMetadata.Title,
showMetadata.Year?.ToString(), showMetadata.Year?.ToString(),
showMetadata.SortTitle, showMetadata.SortTitle,
GetPoster(showMetadata, maybeJellyfin, maybeEmby)); GetPoster(showMetadata, maybeJellyfin, maybeEmby),
showMetadata.Show.State);
internal static TelevisionSeasonCardViewModel ProjectToViewModel( internal static TelevisionSeasonCardViewModel ProjectToViewModel(
Season season, Season season,
@@ -34,7 +36,8 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(season.SeasonNumber), GetSeasonName(season.SeasonNumber),
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby)) season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby))
.IfNone(string.Empty), .IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString()); season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString(),
season.State);
internal static TelevisionSeasonCardViewModel ProjectToViewModel( internal static TelevisionSeasonCardViewModel ProjectToViewModel(
SeasonMetadata seasonMetadata, SeasonMetadata seasonMetadata,
@@ -53,7 +56,8 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(seasonMetadata.Season.SeasonNumber), GetSeasonName(seasonMetadata.Season.SeasonNumber),
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}", $"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby), GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString()); seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString(),
seasonMetadata.Season.State);
} }
internal static TelevisionEpisodeCardViewModel ProjectToViewModel( internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
@@ -80,7 +84,9 @@ namespace ErsatzTV.Application.MediaCards
? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby) ? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby), : GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(), episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList()); episodeMetadata.Writers.Map(w => w.Name).ToList(),
episodeMetadata.Episode.State,
episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path);
internal static MovieCardViewModel ProjectToViewModel( internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata, MovieMetadata movieMetadata,
@@ -91,7 +97,8 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.Title, movieMetadata.Title,
movieMetadata.Year?.ToString(), movieMetadata.Year?.ToString(),
movieMetadata.SortTitle, movieMetadata.SortTitle,
GetPoster(movieMetadata, maybeJellyfin, maybeEmby)); GetPoster(movieMetadata, maybeJellyfin, maybeEmby),
movieMetadata.Movie.State);
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) => internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new( new(
@@ -101,14 +108,29 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.SortTitle, musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot, musicVideoMetadata.Plot,
musicVideoMetadata.Album, musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None)); GetThumbnail(musicVideoMetadata, None, None),
musicVideoMetadata.MusicVideo.State,
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path);
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) => internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
new( new(
otherVideoMetadata.OtherVideoId, otherVideoMetadata.OtherVideoId,
otherVideoMetadata.Title, otherVideoMetadata.Title,
otherVideoMetadata.OriginalTitle, otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle); otherVideoMetadata.SortTitle,
otherVideoMetadata.OtherVideo.State);
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata)
{
string album = string.IsNullOrWhiteSpace(songMetadata.Album) ? "" : $" - {songMetadata.Album}";
return new SongCardViewModel(
songMetadata.SongId,
songMetadata.Title,
songMetadata.Artist + album,
songMetadata.SortTitle,
GetThumbnail(songMetadata, None, None),
songMetadata.Song.State);
}
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) => internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new( new(
@@ -116,7 +138,8 @@ namespace ErsatzTV.Application.MediaCards
artistMetadata.Title, artistMetadata.Title,
artistMetadata.Disambiguation, artistMetadata.Disambiguation,
artistMetadata.SortTitle, artistMetadata.SortTitle,
GetThumbnail(artistMetadata, None, None)); GetThumbnail(artistMetadata, None, None),
artistMetadata.Artist.State);
internal static CollectionCardResultsViewModel internal static CollectionCardResultsViewModel
ProjectToViewModel( ProjectToViewModel(
@@ -141,7 +164,9 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(), collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head())) collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList(), .ToList(),
collection.MediaItems.OfType<OtherVideo>().Map(mv => ProjectToViewModel(mv.OtherVideoMetadata.Head())) collection.MediaItems.OfType<OtherVideo>().Map(ov => ProjectToViewModel(ov.OtherVideoMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<Song>().Map(s => ProjectToViewModel(s.SongMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder }; .ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel( internal static ActorCardViewModel ProjectToViewModel(
@@ -162,7 +187,7 @@ namespace ErsatzTV.Application.MediaCards
.SetQueryParam("maxHeight", 440); .SetQueryParam("maxHeight", 440);
} }
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork); return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork, MediaItemState.Normal);
} }
private static int GetCustomIndex(Collection collection, int mediaItemId) => private static int GetCustomIndex(Collection collection, int mediaItemId) =>
@@ -1,4 +1,12 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record MediaCardViewModel(int MediaItemId, string Title, string Subtitle, string SortTitle, string Poster); public record MediaCardViewModel(
int MediaItemId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State);
} }
@@ -1,12 +1,21 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record MovieCardViewModel public record MovieCardViewModel
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel( (
MovieId, int MovieId,
Title, string Title,
Subtitle, string Subtitle,
SortTitle, string SortTitle,
Poster) string Poster,
MediaItemState State) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster,
State)
{ {
public int CustomIndex { get; set; } public int CustomIndex { get; set; }
} }
@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record MusicVideoCardViewModel public record MusicVideoCardViewModel
( (
@@ -8,12 +10,15 @@
string SortTitle, string SortTitle,
string Plot, string Plot,
string Album, string Album,
string Poster) : MediaCardViewModel( string Poster,
MediaItemState State,
string Path) : MediaCardViewModel(
MusicVideoId, MusicVideoId,
Title, Title,
Subtitle, Subtitle,
SortTitle, SortTitle,
Poster) Poster,
State)
{ {
public int CustomIndex { get; set; } public int CustomIndex { get; set; }
} }
@@ -1,16 +1,20 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record OtherVideoCardViewModel public record OtherVideoCardViewModel
( (
int OtherVideoId, int OtherVideoId,
string Title, string Title,
string Subtitle, string Subtitle,
string SortTitle) : MediaCardViewModel( string SortTitle,
MediaItemState State) : MediaCardViewModel(
OtherVideoId, OtherVideoId,
Title, Title,
Subtitle, Subtitle,
SortTitle, SortTitle,
null) null,
State)
{ {
public int CustomIndex { get; set; } public int CustomIndex { get; set; }
} }
@@ -30,7 +30,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
GetCollectionCards request, GetCollectionCards request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone()); .Map(list => list.HeadOrNone());
@@ -83,6 +83,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata) .ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Artwork) .ThenInclude(ovm => ovm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(ovm => ovm.Artwork)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id) .SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection"))) .Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby)); .MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record SongCardResultsViewModel(
int Count,
List<SongCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record SongCardViewModel
(
int SongId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
SongId,
Title,
Subtitle,
SortTitle,
Poster,
State)
{
public int CustomIndex { get; set; }
}
}
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards namespace ErsatzTV.Application.MediaCards
{ {
@@ -17,10 +18,13 @@ namespace ErsatzTV.Application.MediaCards
string Plot, string Plot,
string Poster, string Poster,
List<string> Directors, List<string> Directors,
List<string> Writers) : MediaCardViewModel( List<string> Writers,
MediaItemState State,
string Path) : MediaCardViewModel(
EpisodeId, EpisodeId,
Title, Title,
$"Episode {Episode}", $"Episode {Episode}",
SortTitle, SortTitle,
Poster); Poster,
State);
} }
@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record TelevisionSeasonCardViewModel public record TelevisionSeasonCardViewModel
( (
@@ -9,10 +11,12 @@
string Subtitle, string Subtitle,
string SortTitle, string SortTitle,
string Poster, string Poster,
string Placeholder) : MediaCardViewModel( string Placeholder,
MediaItemState State) : MediaCardViewModel(
TelevisionSeasonId, TelevisionSeasonId,
Title, Title,
Subtitle, Subtitle,
SortTitle, SortTitle,
Poster); Poster,
State);
} }
@@ -1,10 +1,19 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record TelevisionShowCardViewModel public record TelevisionShowCardViewModel
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel( (
TelevisionShowId, int TelevisionShowId,
Title, string Title,
Subtitle, string Subtitle,
SortTitle, string SortTitle,
Poster); string Poster,
MediaItemState State) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster,
State);
} }
@@ -13,5 +13,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands
List<int> EpisodeIds, List<int> EpisodeIds,
List<int> ArtistIds, List<int> ArtistIds,
List<int> MusicVideoIds, List<int> MusicVideoIds,
List<int> OtherVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>; List<int> OtherVideoIds,
List<int> SongIds) : MediatR.IRequest<Either<BaseError, Unit>>;
} }
@@ -57,6 +57,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Append(request.ArtistIds) .Append(request.ArtistIds)
.Append(request.MusicVideoIds) .Append(request.MusicVideoIds)
.Append(request.OtherVideoIds) .Append(request.OtherVideoIds)
.Append(request.SongIds)
.ToList(); .ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList(); var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddSongToCollection
(int CollectionId, int SongId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,80 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddSongToCollectionHandler :
MediatR.IRequestHandler<AddSongToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public AddSongToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
AddSongToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddSongRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddSongRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Song);
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,
AddSongToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateSong(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext,
AddSongToCollection 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, Song>> ValidateSong(
TvContext dbContext,
AddSongToCollection request) =>
dbContext.Songs
.SelectOneAsync(m => m.Id, e => e.Id == request.SongId)
.Map(o => o.ToValidation<BaseError>("Song does not exist"));
private record Parameters(Collection Collection, Song Song);
}
}
@@ -60,7 +60,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name)); .Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createCollection.Name) var result2 = Optional(createCollection.Name)
.Filter(name => !allNames.Contains(name)) .Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("Collection name must be unique"); .ToValidation<BaseError>("Collection name must be unique");
return (result1, result2).Apply((_, _) => createCollection.Name); return (result1, result2).Apply((_, _) => createCollection.Name);
@@ -104,7 +104,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name)); .Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createMultiCollection.Name) var result2 = Optional(createMultiCollection.Name)
.Filter(name => !allNames.Contains(name)) .Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique"); .ToValidation<BaseError>("MultiCollection name must be unique");
return (result1, result2).Apply((_, _) => createMultiCollection.Name); return (result1, result2).Apply((_, _) => createMultiCollection.Name);
@@ -61,7 +61,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name)); .Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createSmartCollection.Name) var result2 = Optional(createSmartCollection.Name)
.Filter(name => !allNames.Contains(name)) .Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("SmartCollection name must be unique"); .ToValidation<BaseError>("SmartCollection name must be unique");
return (result1, result2).Apply((_, _) => createSmartCollection.Name); return (result1, result2).Apply((_, _) => createSmartCollection.Name);
@@ -39,7 +39,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{ {
try try
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId); Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
return await validation.Match( return await validation.Match(
@@ -208,6 +208,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
var guids = item.Guids.Map(g => g.Guid).ToList(); var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeMovieByGuid = await dbContext.MovieMetadata Option<int> maybeMovieByGuid = await dbContext.MovieMetadata
.AsNoTracking()
.Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid))) .Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -220,6 +221,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
} }
Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata
.AsNoTracking()
.Filter(mm => mm.Title == item.Title && mm.Year == item.Year) .Filter(mm => mm.Title == item.Title && mm.Year == item.Year)
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -241,6 +243,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
var guids = item.Guids.Map(g => g.Guid).ToList(); var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeShowByGuid = await dbContext.ShowMetadata Option<int> maybeShowByGuid = await dbContext.ShowMetadata
.AsNoTracking()
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid))) .Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -253,6 +256,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
} }
Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata
.AsNoTracking()
.Filter(sm => sm.Title == item.Title && sm.Year == item.Year) .Filter(sm => sm.Title == item.Title && sm.Year == item.Year)
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -274,6 +278,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
var guids = item.Guids.Map(g => g.Guid).ToList(); var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata
.AsNoTracking()
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid))) .Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -286,6 +291,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
} }
Option<int> maybeSeasonByTitleYear = await dbContext.SeasonMetadata Option<int> maybeSeasonByTitleYear = await dbContext.SeasonMetadata
.AsNoTracking()
.Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year)) .Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
.Filter(sm => sm.Season.SeasonNumber == item.Season) .Filter(sm => sm.Season.SeasonNumber == item.Season)
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
@@ -308,6 +314,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
var guids = item.Guids.Map(g => g.Guid).ToList(); var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(em => em.Guids.Any(g => guids.Contains(g.Guid))) .Filter(em => em.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -320,6 +327,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
} }
Option<int> maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata Option<int> maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(sm => sm.Episode.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year)) .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(em => em.Episode.Season.SeasonNumber == item.Season)
.Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode)) .Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode))
@@ -156,7 +156,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Bind(_ => updateMultiCollection.NotLongerThan(50)(c => c.Name)); .Bind(_ => updateMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(updateMultiCollection.Name) var result2 = Optional(updateMultiCollection.Name)
.Filter(name => !allNames.Contains(name)) .Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique"); .ToValidation<BaseError>("MultiCollection name must be unique");
return (result1, result2).Apply((_, _) => updateMultiCollection.Name); return (result1, result2).Apply((_, _) => updateMultiCollection.Name);
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.MediaCollections
internal static class Mapper internal static class Mapper
{ {
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) => internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder); new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder, MediaItemState.Normal);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) => internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new( new(
@@ -1,11 +1,17 @@
using ErsatzTV.Application.MediaCards; using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections namespace ErsatzTV.Application.MediaCollections
{ {
public record MediaCollectionViewModel(int Id, string Name, bool UseCustomPlaybackOrder) : MediaCardViewModel( public record MediaCollectionViewModel(
int Id,
string Name,
bool UseCustomPlaybackOrder,
MediaItemState State) : MediaCardViewModel(
Id, Id,
Name, Name,
string.Empty, string.Empty,
Name, Name,
string.Empty); string.Empty,
State);
} }
@@ -27,6 +27,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private readonly IMovieFolderScanner _movieFolderScanner; private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner; private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner; private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
private readonly ISongFolderScanner _songFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner; private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler( public ScanLocalLibraryHandler(
@@ -36,6 +37,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
ITelevisionFolderScanner televisionFolderScanner, ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner, IMusicVideoFolderScanner musicVideoFolderScanner,
IOtherVideoFolderScanner otherVideoFolderScanner, IOtherVideoFolderScanner otherVideoFolderScanner,
ISongFolderScanner songFolderScanner,
IEntityLocker entityLocker, IEntityLocker entityLocker,
IMediator mediator, IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger) ILogger<ScanLocalLibraryHandler> logger)
@@ -46,6 +48,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
_televisionFolderScanner = televisionFolderScanner; _televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner; _musicVideoFolderScanner = musicVideoFolderScanner;
_otherVideoFolderScanner = otherVideoFolderScanner; _otherVideoFolderScanner = otherVideoFolderScanner;
_songFolderScanner = songFolderScanner;
_entityLocker = entityLocker; _entityLocker = entityLocker;
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
@@ -67,7 +70,8 @@ namespace ErsatzTV.Application.MediaSources.Commands
private async Task<Unit> PerformScan(RequestParameters parameters) private async Task<Unit> PerformScan(RequestParameters parameters)
{ {
(LocalLibrary localLibrary, string ffprobePath, bool forceScan, int libraryRefreshInterval) = parameters; (LocalLibrary localLibrary, string ffprobePath, string ffmpegPath, bool forceScan,
int libraryRefreshInterval) = parameters;
var sw = new Stopwatch(); var sw = new Stopwatch();
sw.Start(); sw.Start();
@@ -117,6 +121,14 @@ namespace ErsatzTV.Application.MediaSources.Commands
progressMin, progressMin,
progressMax); progressMax);
break; break;
case LibraryMediaKind.Songs:
await _songFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffmpegPath,
progressMin,
progressMax);
break;
} }
libraryPath.LastScan = DateTime.UtcNow; libraryPath.LastScan = DateTime.UtcNow;
@@ -148,14 +160,36 @@ namespace ErsatzTV.Application.MediaSources.Commands
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request) => private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request)
(await LocalLibraryMustExist(request), await ValidateFFprobePath(), await ValidateLibraryRefreshInterval()) {
.Apply( Validation<BaseError, LocalLibrary> libraryResult = await LocalLibraryMustExist(request);
(library, ffprobePath, libraryRefreshInterval) => new RequestParameters( Validation<BaseError, string> ffprobePathResult = await ValidateFFprobePath();
library, Validation<BaseError, string> ffmpegPathResult = await ValidateFFmpegPath();
ffprobePath, Validation<BaseError, int> refreshIntervalResult = await ValidateLibraryRefreshInterval();
request.ForceScan,
libraryRefreshInterval)); try
{
return (libraryResult, ffprobePathResult, ffmpegPathResult, refreshIntervalResult)
.Apply(
(library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
library,
ffprobePath,
ffmpegPath,
request.ForceScan,
libraryRefreshInterval));
}
finally
{
// ensure we unlock the library if any validation is unsuccessful
foreach (LocalLibrary library in libraryResult.SuccessToSeq())
{
if (ffprobePathResult.IsFail || ffmpegPathResult.IsFail || refreshIntervalResult.IsFail)
{
_entityLocker.UnlockLibrary(library.Id);
}
}
}
}
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist( private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
IScanLocalLibrary request) => IScanLocalLibrary request) =>
@@ -170,6 +204,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
ffprobePath => ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system")); ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(
ffmpegPath =>
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() => private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval) _configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0) .FilterT(lri => lri > 0)
@@ -178,6 +219,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private record RequestParameters( private record RequestParameters(
LocalLibrary LocalLibrary, LocalLibrary LocalLibrary,
string FFprobePath, string FFprobePath,
string FFmpegPath,
bool ForceScan, bool ForceScan,
int LibraryRefreshInterval); int LibraryRefreshInterval);
} }
+4 -1
View File
@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq; using System.Linq;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using Flurl; using Flurl;
using LanguageExt; using LanguageExt;
@@ -34,7 +35,9 @@ namespace ErsatzTV.Application.Movies
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby)) .Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList(), .ToList(),
metadata.Directors.Map(d => d.Name).ToList(), metadata.Directors.Map(d => d.Name).ToList(),
metadata.Writers.Map(w => w.Name).ToList()) metadata.Writers.Map(w => w.Name).ToList(),
movie.GetHeadVersion().MediaFiles.Head().Path,
movie.State)
{ {
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby) FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby)
@@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using ErsatzTV.Application.MediaCards; using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Movies namespace ErsatzTV.Application.Movies
{ {
@@ -15,7 +16,9 @@ namespace ErsatzTV.Application.Movies
List<CultureInfo> Languages, List<CultureInfo> Languages,
List<ActorCardViewModel> Actors, List<ActorCardViewModel> Actors,
List<string> Directors, List<string> Directors,
List<string> Writers) List<string> Writers,
string Path,
MediaItemState MediaItemState)
{ {
public string Poster { get; set; } public string Poster { get; set; }
public string FanArt { get; set; } public string FanArt { get; set; }
@@ -33,7 +33,7 @@ namespace ErsatzTV.Application.Movies.Queries
GetMovieById request, GetMovieById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone()); .Map(list => list.HeadOrNone());
+8
View File
@@ -48,6 +48,14 @@ namespace ErsatzTV.Application.Playouts
.Map(ovm => ovm.Title ?? string.Empty) .Map(ovm => ovm.Title ?? string.Empty)
.Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? s : $"{s} ({playoutItem.ChapterTitle})") .Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? s : $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown video]"); .IfNone("[unknown video]");
case Song s:
string songArtist = s.SongMetadata.HeadOrNone()
.Map(sm => string.IsNullOrWhiteSpace(sm.Artist) ? string.Empty : $"{sm.Artist} - ")
.IfNone(string.Empty);
return s.SongMetadata.HeadOrNone()
.Map(sm => $"{songArtist}{sm.Title ?? string.Empty}")
.Map(t => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? t : $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown song]");
default: default:
return string.Empty; return string.Empty;
} }
@@ -24,7 +24,7 @@ namespace ErsatzTV.Application.Playouts.Queries
GetFuturePlayoutItemsById request, GetFuturePlayoutItemsById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
DateTime now = DateTimeOffset.Now.UtcDateTime; DateTime now = DateTimeOffset.Now.UtcDateTime;
@@ -57,6 +57,10 @@ namespace ErsatzTV.Application.Playouts.Queries
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata) .ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions) .ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.Filter(i => i.PlayoutId == request.PlayoutId) .Filter(i => i.PlayoutId == request.PlayoutId)
.Filter(i => i.Finish >= now) .Filter(i => i.Finish >= now)
.Filter(i => request.ShowFiller || i.FillerKind == FillerKind.None) .Filter(i => request.ShowFiller || i.FillerKind == FillerKind.None)
@@ -65,7 +65,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
.CountAsync(ps => ps.Name == createProgramSchedule.Name); .CountAsync(ps => ps.Name == createProgramSchedule.Name);
var result2 = Optional(duplicateNameCount) var result2 = Optional(duplicateNameCount)
.Filter(count => count == 0) .Where(count => count == 0)
.ToValidation<BaseError>("Schedule name must be unique"); .ToValidation<BaseError>("Schedule name must be unique");
return (result1, result2).Apply((_, _) => createProgramSchedule.Name); return (result1, result2).Apply((_, _) => createProgramSchedule.Name);
@@ -169,7 +169,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
{ {
ProgramScheduleId = programSchedule.Id, ProgramScheduleId = programSchedule.Id,
Index = index, Index = index,
StartTime = FixDuration(item.StartTime.GetValueOrDefault()), StartTime = FixStartTime(item.StartTime),
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId, MultiCollectionId = item.MultiCollectionId,
@@ -188,7 +188,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
{ {
ProgramScheduleId = programSchedule.Id, ProgramScheduleId = programSchedule.Id,
Index = index, Index = index,
StartTime = FixDuration(item.StartTime.GetValueOrDefault()), StartTime = FixStartTime(item.StartTime),
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId, MultiCollectionId = item.MultiCollectionId,
@@ -207,7 +207,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
{ {
ProgramScheduleId = programSchedule.Id, ProgramScheduleId = programSchedule.Id,
Index = index, Index = index,
StartTime = FixDuration(item.StartTime.GetValueOrDefault()), StartTime = FixStartTime(item.StartTime),
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId, MultiCollectionId = item.MultiCollectionId,
@@ -227,7 +227,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
{ {
ProgramScheduleId = programSchedule.Id, ProgramScheduleId = programSchedule.Id,
Index = index, Index = index,
StartTime = FixDuration(item.StartTime.GetValueOrDefault()), StartTime = FixStartTime(item.StartTime),
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId, MultiCollectionId = item.MultiCollectionId,
@@ -249,5 +249,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
private static TimeSpan FixDuration(TimeSpan duration) => private static TimeSpan FixDuration(TimeSpan duration) =>
duration >= TimeSpan.FromDays(1) ? duration.Subtract(TimeSpan.FromDays(1)) : duration; duration >= TimeSpan.FromDays(1) ? duration.Subtract(TimeSpan.FromDays(1)) : duration;
private static TimeSpan? FixStartTime(TimeSpan? startTime) =>
startTime.HasValue && startTime.Value >= TimeSpan.FromDays(1)
? startTime.Value.Subtract(TimeSpan.FromDays(1))
: startTime;
} }
} }
@@ -26,7 +26,8 @@ namespace ErsatzTV.Application.Search.Queries
await GetIds(SearchIndex.EpisodeType, request.Query), await GetIds(SearchIndex.EpisodeType, request.Query),
await GetIds(SearchIndex.ArtistType, request.Query), await GetIds(SearchIndex.ArtistType, request.Query),
await GetIds(SearchIndex.MusicVideoType, request.Query), await GetIds(SearchIndex.MusicVideoType, request.Query),
await GetIds(SearchIndex.OtherVideoType, request.Query)); await GetIds(SearchIndex.OtherVideoType, request.Query),
await GetIds(SearchIndex.SongType, request.Query));
private Task<List<int>> GetIds(string type, string query) => private Task<List<int>> GetIds(string type, string query) =>
_searchIndex.Search($"type:{type} AND ({query})", 0, 0) _searchIndex.Search($"type:{type} AND ({query})", 0, 0)
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexSongs
(string Query, int PageNumber, int PageSize) : IRequest<SongCardResultsViewModel>;
}
@@ -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
QuerySearchIndexSongsHandler : IRequestHandler<QuerySearchIndexSongs,
SongCardResultsViewModel>
{
private readonly ISongRepository _songRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexSongsHandler(ISearchIndex searchIndex, ISongRepository songRepository)
{
_searchIndex = searchIndex;
_songRepository = songRepository;
}
public async Task<SongCardResultsViewModel> Handle(
QuerySearchIndexSongs request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<SongCardViewModel> items = await _songRepository
.GetSongsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new SongCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Search
List<int> EpisodeIds, List<int> EpisodeIds,
List<int> ArtistIds, List<int> ArtistIds,
List<int> MusicVideoIds, List<int> MusicVideoIds,
List<int> OtherVideoIds); List<int> OtherVideoIds,
List<int> SongIds);
} }
@@ -83,7 +83,7 @@ namespace ErsatzTV.Application.Streaming.Commands
private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request) private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
{ {
var result = Optional(_ffmpegSegmenterService.SessionWorkers.TryAdd(request.ChannelNumber, null)) var result = Optional(_ffmpegSegmenterService.SessionWorkers.TryAdd(request.ChannelNumber, null))
.Filter(success => success) .Where(success => success)
.Map(_ => Unit.Default) .Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelSessionAlreadyActive()); .ToValidation<BaseError>(new ChannelSessionAlreadyActive());
@@ -23,7 +23,7 @@ namespace ErsatzTV.Application.Streaming.Queries
public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken) public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request); Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
return await validation.Match( return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2), tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2),
@@ -56,7 +56,8 @@ namespace ErsatzTV.Application.Streaming.Queries
{ {
"hls-direct" => StreamingMode.HttpLiveStreamingDirect, "hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter, "segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStream, "ts" => StreamingMode.TransportStreamHybrid,
"ts-legacy" => StreamingMode.TransportStream,
_ => channel.StreamingMode _ => channel.StreamingMode
}; };
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{ {
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base( public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber, channelNumber,
"ts", "ts-legacy",
DateTimeOffset.Now, DateTimeOffset.Now,
false, false,
true) true)
@@ -1,11 +1,9 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
@@ -15,17 +13,14 @@ namespace ErsatzTV.Application.Streaming.Queries
{ {
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber> public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
{ {
private readonly FFmpegProcessService _ffmpegProcessService; private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IRuntimeInfo _runtimeInfo;
public GetConcatProcessByChannelNumberHandler( public GetConcatProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
FFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService)
IRuntimeInfo runtimeInfo)
: base(dbContextFactory) : base(dbContextFactory)
{ {
_ffmpegProcessService = ffmpegProcessService; _ffmpegProcessService = ffmpegProcessService;
_runtimeInfo = runtimeInfo;
} }
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess( protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -34,7 +29,7 @@ namespace ErsatzTV.Application.Streaming.Queries
Channel channel, Channel channel,
string ffmpegPath) string ffmpegPath)
{ {
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports) .GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false)); .Map(result => result.IfNone(false));
@@ -1,17 +1,20 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Runtime; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
@@ -24,20 +27,26 @@ namespace ErsatzTV.Application.Streaming.Queries
FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber> FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{ {
private readonly IEmbyPathReplacementService _embyPathReplacementService; private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly FFmpegProcessService _ffmpegProcessService; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
private readonly IArtistRepository _artistRepository;
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRuntimeInfo _runtimeInfo; private readonly ISongVideoGenerator _songVideoGenerator;
public GetPlayoutItemProcessByChannelNumberHandler( public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
FFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IPlexPathReplacementService plexPathReplacementService, IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService, IEmbyPathReplacementService embyPathReplacementService,
IRuntimeInfo runtimeInfo) IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
ISongVideoGenerator songVideoGenerator)
: base(dbContextFactory) : base(dbContextFactory)
{ {
_ffmpegProcessService = ffmpegProcessService; _ffmpegProcessService = ffmpegProcessService;
@@ -45,7 +54,10 @@ namespace ErsatzTV.Application.Streaming.Queries
_plexPathReplacementService = plexPathReplacementService; _plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService; _jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService; _embyPathReplacementService = embyPathReplacementService;
_runtimeInfo = runtimeInfo; _mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_songVideoGenerator = songVideoGenerator;
} }
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess( protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -81,25 +93,34 @@ namespace ErsatzTV.Application.Streaming.Queries
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions) .ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(ov => ov.Streams) .ThenInclude(ov => ov.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.ThenInclude(sm => sm.Artwork)
.ForChannelAndTime(channel.Id, now) .ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem())) .Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath); .BindT(ValidatePlayoutItemPath);
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
{
maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, now);
}
return await maybePlayoutItem.Match( return await maybePlayoutItem.Match(
async playoutItemWithPath => async playoutItemWithPath =>
{ {
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem switch MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
};
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements string videoPath = playoutItemWithPath.Path;
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports) MediaVersion videoVersion = version;
.Map(result => result.IfNone(false));
string audioPath = playoutItemWithPath.Path;
MediaVersion audioVersion = version;
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId) .GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId)
@@ -107,12 +128,27 @@ namespace ErsatzTV.Application.Streaming.Queries
watermarkId => dbContext.ChannelWatermarks watermarkId => dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId)); .SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
{
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
song,
channel,
maybeGlobalWatermark,
ffmpegPath);
}
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Process process = await _ffmpegProcessService.ForPlayoutItem( Process process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath, ffmpegPath,
saveReports, saveReports,
channel, channel,
version, videoVersion,
playoutItemWithPath.Path, audioVersion,
videoPath,
audioPath,
playoutItemWithPath.PlayoutItem.StartOffset, playoutItemWithPath.PlayoutItem.StartOffset,
playoutItemWithPath.PlayoutItem.FinishOffset, playoutItemWithPath.PlayoutItem.FinishOffset,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now, request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
@@ -134,7 +170,7 @@ namespace ErsatzTV.Application.Streaming.Queries
$"offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'"; $"offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'";
Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode) Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
.Filter(transcode => transcode) .Where(transcode => transcode)
.Match( .Match(
_ => dbContext.PlayoutItems _ => dbContext.PlayoutItems
.Filter(pi => pi.Playout.ChannelId == channel.Id) .Filter(pi => pi.Playout.ChannelId == channel.Id)
@@ -152,14 +188,13 @@ namespace ErsatzTV.Application.Streaming.Queries
case UnableToLocatePlayoutItem: case UnableToLocatePlayoutItem:
if (channel.FFmpegProfile.Transcode) if (channel.FFmpegProfile.Transcode)
{ {
Process errorProcess = _ffmpegProcessService.ForError( Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
maybeDuration, maybeDuration,
"Channel is Offline", "Channel is Offline",
request.HlsRealtime); request.HlsRealtime);
return new PlayoutItemProcessModel(errorProcess, finish); return new PlayoutItemProcessModel(errorProcess, finish);
} }
else else
@@ -172,7 +207,7 @@ namespace ErsatzTV.Application.Streaming.Queries
case PlayoutItemDoesNotExistOnDisk: case PlayoutItemDoesNotExistOnDisk:
if (channel.FFmpegProfile.Transcode) if (channel.FFmpegProfile.Transcode)
{ {
Process errorProcess = _ffmpegProcessService.ForError( Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
maybeDuration, maybeDuration,
@@ -191,7 +226,7 @@ namespace ErsatzTV.Application.Streaming.Queries
default: default:
if (channel.FFmpegProfile.Transcode) if (channel.FFmpegProfile.Transcode)
{ {
Process errorProcess = _ffmpegProcessService.ForError( Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
maybeDuration, maybeDuration,
@@ -211,6 +246,88 @@ namespace ErsatzTV.Application.Streaming.Queries
}); });
} }
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
TvContext dbContext,
Channel channel,
DateTimeOffset now)
{
// check for channel fallback
Option<FillerPreset> maybeFallback = await dbContext.FillerPresets
.SelectOneAsync(w => w.Id, w => w.Id == channel.FallbackFillerId);
// then check for global fallback
if (maybeFallback.IsNone)
{
maybeFallback = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId)
.BindT(fillerId => dbContext.FillerPresets.SelectOneAsync(w => w.Id, w => w.Id == fillerId));
}
foreach (FillerPreset fallbackPreset in maybeFallback)
{
// turn this into a playout item
var collectionKey = CollectionKey.ForFillerPreset(fallbackPreset);
List<MediaItem> items = await MediaItemsForCollection.Collect(
_mediaCollectionRepository,
_televisionRepository,
_artistRepository,
collectionKey);
// TODO: shuffle? does it really matter since we loop anyway
MediaItem item = items[new Random().Next(items.Count)];
Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
.Where(transcode => transcode)
.Match(
_ => dbContext.PlayoutItems
.Filter(pi => pi.Playout.ChannelId == channel.Id)
.Filter(pi => pi.Start > now.UtcDateTime)
.OrderBy(pi => pi.Start)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(pi => pi.StartOffset - now),
() => Option<TimeSpan>.None.AsTask());
MediaVersion version = item.GetHeadVersion();
version.MediaFiles = await dbContext.MediaFiles
.AsNoTracking()
.Filter(mf => mf.MediaVersionId == version.Id)
.ToListAsync();
version.Streams = await dbContext.MediaStreams
.AsNoTracking()
.Filter(ms => ms.MediaVersionId == version.Id)
.ToListAsync();
DateTimeOffset finish = maybeDuration.Match(
// next playout item exists
// loop until it starts
now.Add,
// no next playout item exists
// loop for 5 minutes if less than 30s, otherwise play full item
() => version.Duration < TimeSpan.FromSeconds(30)
? now.AddMinutes(5)
: now.Add(version.Duration));
var playoutItem = new PlayoutItem
{
MediaItem = item,
MediaItemId = item.Id,
Start = now.UtcDateTime,
Finish = finish.UtcDateTime,
FillerKind = FillerKind.Fallback,
InPoint = TimeSpan.Zero,
OutPoint = version.Duration
};
return await ValidatePlayoutItemPath(playoutItem);
}
return new UnableToLocatePlayoutItem();
}
private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(PlayoutItem playoutItem) private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(PlayoutItem playoutItem)
{ {
string path = await GetPlayoutItemPath(playoutItem); string path = await GetPlayoutItemPath(playoutItem);
@@ -225,14 +342,7 @@ namespace ErsatzTV.Application.Streaming.Queries
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem) private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem)
{ {
MediaVersion version = playoutItem.MediaItem switch MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
};
MediaFile file = version.MediaFiles.Head(); MediaFile file = version.MediaFiles.Head();
string path = file.Path; string path = file.Path;
@@ -0,0 +1,21 @@
using System;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
{
public GetWrappedProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts",
DateTimeOffset.Now,
false,
true)
{
Scheme = scheme;
Host = host;
}
public string Scheme { get; }
public string Host { get; }
}
}
@@ -0,0 +1,46 @@
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming.Queries
{
public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetWrappedProcessByChannelNumber>
{
private readonly IFFmpegProcessService _ffmpegProcessService;
public GetWrappedProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetWrappedProcessByChannelNumber request,
Channel channel,
string ffmpegPath)
{
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Process process = _ffmpegProcessService.WrapSegmenter(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, DateTimeOffset.MaxValue);
}
}
}
@@ -1,106 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands
{
[Command("channel", Description = "Create or rename a channel")]
public class ChannelCommand : ICommand
{
private readonly ChannelsApi _channelsApi;
private readonly FFmpegProfileApi _ffmpegProfileApi;
private readonly ILogger<ChannelCommand> _logger;
public ChannelCommand(IConfiguration configuration, ILogger<ChannelCommand> logger)
{
_logger = logger;
_channelsApi = new ChannelsApi(configuration["ServerUrl"]);
_ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]);
}
[CommandParameter(0, Name = "channel-number", Description = "The channel number")]
public int Number { get; set; }
[CommandParameter(1, Name = "channel-name", Description = "The channel name")]
public string Name { get; set; }
[CommandParameter(2, Name = "streaming-mode", Description = "The streaming mode")]
public StreamingMode StreamingMode { get; set; }
[CommandOption("ffmpeg-profile", Description = "The ffmpeg profile name")]
public string FFmpegProfileName { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
Option<ChannelViewModel> maybeChannel = await _channelsApi.ApiChannelsGetAsync()
.Map(list => Optional(list.SingleOrDefault(c => c.Number == Number)));
FFmpegProfileViewModel ffmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync()
.Map(
list => Optional(list.SingleOrDefault(p => p.Name == FFmpegProfileName))
.IfNone(new FFmpegProfileViewModel { Id = 1 }));
await maybeChannel.Match(
channel => RenameChannel(channel, ffmpegProfile),
() => AddChannel(ffmpegProfile));
}
catch (Exception ex)
{
_logger.LogError("Unable to synchronize channel: {Message}", ex.Message);
}
}
private async ValueTask RenameChannel(ChannelViewModel existing, FFmpegProfileViewModel ffmpegProfile)
{
int newFFmpegProfileId = string.IsNullOrWhiteSpace(FFmpegProfileName)
? existing.FfmpegProfileId
: ffmpegProfile.Id;
if (existing.Name != Name || existing.FfmpegProfileId != newFFmpegProfileId ||
existing.StreamingMode != StreamingMode)
{
var updateChannel = new UpdateChannel(
existing.Id,
Name,
existing.Number,
newFFmpegProfileId,
existing.Logo,
StreamingMode);
await _channelsApi.ApiChannelsPatchAsync(updateChannel);
}
_logger.LogInformation(
"Successfully synchronized channel {ChannelNumber} - {ChannelName}",
Number,
Name);
}
private async ValueTask AddChannel(FFmpegProfileViewModel ffmpegProfile)
{
var createChannel = new CreateChannel(
Name,
Number,
ffmpegProfile.Id,
null,
StreamingMode);
await _channelsApi.ApiChannelsPostAsync(createChannel);
_logger.LogInformation(
"Successfully created channel {ChannelNumber} - {ChannelName}",
Number,
Name);
}
}
}
@@ -1,31 +0,0 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
namespace ErsatzTV.CommandLine.Commands
{
[Command("config", Description = "Configure ErsatzTV server url")]
public class ConfigCommand : ICommand
{
[CommandParameter(0, Name = "server-url", Description = "The url of the ErsatzTV server")]
public string ServerUrl { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
// TODO: validate URL
string configFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ersatztv");
string configFile = Path.Combine(configFolder, "cli.json");
var config = new Config { ServerUrl = ServerUrl };
string contents = JsonSerializer.Serialize(config);
await File.WriteAllTextAsync(configFile, contents);
}
}
}
@@ -1,151 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands
{
[Command("ffmpeg-profile", Description = "Synchronize an ffmpeg profile")]
public class FFmpegProfileCommand : ICommand
{
private readonly FFmpegProfileApi _ffmpegProfileApi;
private readonly ILogger<FFmpegProfileCommand> _logger;
public FFmpegProfileCommand(IConfiguration configuration, ILogger<FFmpegProfileCommand> logger)
{
_logger = logger;
_ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]);
}
[CommandParameter(0, Name = "profile-name", Description = "The ffmpeg profile name")]
public string Name { get; set; }
[CommandOption("thread-count", Description = "The number of threads")]
public int ThreadCount { get; set; } = 0;
[CommandOption("transcode", Description = "Whether to transcode all media")]
public bool Transcode { get; set; } = true;
// public int ResolutionId { get; set; } = resolution.Id;
// Resolution { get; set; } = resolution;
[CommandOption("resolution", Description = "The resolution")]
public DesiredResolution Resolution { get; set; } = DesiredResolution.W1920H1080;
[CommandOption("video-codec", Description = "The video codec")]
public string VideoCodec { get; set; } = "libx264";
[CommandOption("audio-codec", Description = "The audio codec")]
public string AudioCodec { get; set; } = "ac3";
[CommandOption("video-bitrate", Description = "The video bitrate in kBit/s")]
public int VideoBitrate { get; set; } = 2000;
[CommandOption("video-buffer-size", Description = "The video buffer size in kBit")]
public int VideoBufferSize { get; set; } = 2000;
[CommandOption("audio-bitrate", Description = "The audio bitrate in kBit/s")]
public int AudioBitrate { get; set; } = 192;
[CommandOption("audio-buffer-size", Description = "The audio buffer size in kBits")]
public int AudioBufferSize { get; set; } = 50;
[CommandOption("audio-volume", Description = "The audio volume as a whole number percent")]
public int AudioVolume { get; set; } = 100;
[CommandOption("audio-channels", Description = "The number of audio channels")]
public int AudioChannels { get; set; } = 2;
[CommandOption("audio-sample-rate", Description = "The audio sample rate in kHz")]
public int AudioSampleRate { get; set; } = 48;
[CommandOption("normalize-resolution", Description = "Whether to normalize the resolution of all media")]
public bool NormalizeResolution { get; set; } = true;
[CommandOption("normalize-video-codec", Description = "Whether to normalize the video codec of all media")]
public bool NormalizeVideoCodec { get; set; } = true;
[CommandOption("normalize-audio-codec", Description = "Whether to normalize the audio codec of all media")]
public bool NormalizeAudioCodec { get; set; } = true;
[CommandOption(
"normalize-audio",
Description = "Whether to normalize audio channels and sample rate of all media")]
public bool NormalizeAudio { get; set; } = true;
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
Option<FFmpegProfileViewModel> maybeFFmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync()
.Map(list => Optional(list.SingleOrDefault(p => p.Name == Name)));
await maybeFFmpegProfile.Match(UpdateProfile, AddProfile);
}
catch (Exception ex)
{
_logger.LogError("Unable to synchronize ffmpeg profile: {Message}", ex.Message);
}
}
private async ValueTask UpdateProfile(FFmpegProfileViewModel existing)
{
var updateFFmpegProfile = new UpdateFFmpegProfile(
existing.Id,
Name,
ThreadCount,
Transcode,
(int) Resolution,
NormalizeResolution,
VideoCodec,
NormalizeVideoCodec,
VideoBitrate,
VideoBufferSize,
AudioCodec,
NormalizeAudioCodec,
AudioBitrate,
AudioBufferSize,
AudioVolume,
AudioChannels,
AudioSampleRate,
NormalizeAudio);
await _ffmpegProfileApi.ApiFfmpegProfilesPatchAsync(updateFFmpegProfile);
_logger.LogInformation("Successfully synchronized ffmpeg profile {ProfileName}", Name);
}
private async ValueTask AddProfile()
{
var createFFmpegProfile = new CreateFFmpegProfile(
Name,
ThreadCount,
Transcode,
(int) Resolution,
NormalizeResolution,
VideoCodec,
NormalizeVideoCodec,
VideoBitrate,
VideoBufferSize,
AudioCodec,
NormalizeAudioCodec,
AudioBitrate,
AudioBufferSize,
AudioVolume,
AudioChannels,
AudioSampleRate,
NormalizeAudio);
await _ffmpegProfileApi.ApiFfmpegProfilesPostAsync(createFFmpegProfile);
_logger.LogInformation("Successfully created ffmpeg profile {ProfileName}", Name);
}
}
}
@@ -1,86 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.MediaCollections
{
[Command("collection clear", Description = "Removes all items from a media collection")]
public class MediaCollectionClearCommand : ICommand
{
private readonly ILogger<MediaCollectionClearCommand> _logger;
private readonly string _serverUrl;
public MediaCollectionClearCommand(IConfiguration configuration, ILogger<MediaCollectionClearCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
public string Name { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await ClearMediaCollection(cancellationToken);
result.Match(
_ => _logger.LogInformation("Successfully cleared media collection {MediaCollection}", Name),
error => _logger.LogError(
"Unable to clear media collection: {Error}",
error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to clear media collection: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> ClearMediaCollection(CancellationToken cancellationToken) =>
await EnsureMediaCollectionExists(cancellationToken)
.BindAsync(mediaCollectionId => ClearMediaCollectionImpl(mediaCollectionId, cancellationToken));
private async Task<Either<Error, int>> EnsureMediaCollectionExists(CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
Option<MediaCollectionViewModel> maybeExisting =
(await mediaCollectionsApi.ApiMediaCollectionsGetAsync(cancellationToken))
.SingleOrDefault(mc => mc.Name == Name);
return await maybeExisting.MatchAsync(
existing => existing.Id,
async () =>
{
var data = new CreateSimpleMediaCollection(Name);
MediaCollectionViewModel result =
await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken);
return result.Id;
});
}
private async Task<Either<Error, Unit>> ClearMediaCollectionImpl(
int mediaCollectionId,
CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
await mediaCollectionsApi.ApiMediaCollectionsIdItemsPutAsync(
mediaCollectionId,
new List<int>(),
cancellationToken);
return unit;
}
}
}
@@ -1,72 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.MediaCollections
{
[Command("collection create", Description = "Creates a new media collection")]
public class MediaCollectionCreateCommand : ICommand
{
private readonly ILogger<MediaCollectionCreateCommand> _logger;
private readonly string _serverUrl;
public MediaCollectionCreateCommand(IConfiguration configuration, ILogger<MediaCollectionCreateCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
public string Name { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await CreateMediaCollection(cancellationToken);
result.IfLeft(error => _logger.LogError("Unable to create media collection: {Error}", error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to create media collection: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> CreateMediaCollection(CancellationToken cancellationToken) =>
await EnsureMediaCollectionExists(cancellationToken);
private async Task<Either<Error, Unit>> EnsureMediaCollectionExists(CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
bool needToAdd = await mediaCollectionsApi
.ApiMediaCollectionsGetAsync(cancellationToken)
.Map(list => list.All(mc => mc.Name != Name));
if (needToAdd)
{
var data = new CreateSimpleMediaCollection(Name);
await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken);
_logger.LogInformation("Successfully created media collection {MediaCollection}", Name);
}
else
{
_logger.LogInformation("Media collection {MediaCollection} is already present", Name);
}
return unit;
}
}
}
@@ -1,107 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.CommandLine.Commands
{
[Command("playout build", Description = "Builds a playout with the requested channel and schedule")]
public class PlayoutCommand : ICommand
{
private readonly ILogger<PlayoutCommand> _logger;
private readonly string _serverUrl;
public PlayoutCommand(IConfiguration configuration, ILogger<PlayoutCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "channel-number", Description = "The channel number")]
public int ChannelNumber { get; set; }
[CommandParameter(1, Name = "schedule-name", Description = "The schedule name")]
public string ScheduleName { get; set; }
// [Option("--type <type>")]
// [Required]
// public ProgramSchedulePlayoutType PlayoutType { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
var channelsApi = new ChannelsApi(_serverUrl);
Option<ChannelViewModel> maybeChannel = await channelsApi.ApiChannelsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(c => c.Number == ChannelNumber));
await maybeChannel.Match(
channel => BuildPlayout(cancellationToken, channel),
() =>
{
_logger.LogError("Unable to locate channel number {ChannelNumber}", ChannelNumber);
return ValueTask.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError("Unable to build playout: {Error}", ex.Message);
}
}
private async ValueTask BuildPlayout(CancellationToken cancellationToken, ChannelViewModel channel)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
Option<ProgramScheduleViewModel> maybeSchedule = await programScheduleApi
.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(s => s.Name == ScheduleName));
await maybeSchedule.Match(
schedule => SynchronizePlayoutAsync(channel.Id, schedule.Id, cancellationToken),
() =>
{
_logger.LogError("Unable to locate schedule {Schedule}", ScheduleName);
return ValueTask.CompletedTask;
});
}
private async ValueTask SynchronizePlayoutAsync(
int channelId,
int scheduleId,
CancellationToken cancellationToken)
{
var playoutApi = new PlayoutApi(_serverUrl);
Option<PlayoutViewModel> maybeExisting = await playoutApi.ApiPlayoutsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(p => p.Channel.Id == channelId));
await maybeExisting.Match(
existing =>
{
var data = new UpdatePlayout(existing.Id, channelId, scheduleId, ProgramSchedulePlayoutType.Flood);
if (existing.Channel.Id != data.ChannelId ||
existing.ProgramSchedule.Id != data.ProgramScheduleId ||
existing.ProgramSchedulePlayoutType != data.ProgramSchedulePlayoutType)
{
return playoutApi.ApiPlayoutsPatchAsync(data, cancellationToken);
}
return Task.CompletedTask;
},
() =>
{
var data = new CreatePlayout(channelId, scheduleId, ProgramSchedulePlayoutType.Flood);
return playoutApi.ApiPlayoutsPostAsync(data, cancellationToken);
});
_logger.LogInformation("Successfully built playout for schedule {Schedule}", ScheduleName);
}
}
}
@@ -1,140 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.CommandLine.Commands.Schedules
{
[Command("schedule add-item", Description = "Adds an item to the end of a schedule")]
public class ScheduleAddItemCommand : ICommand
{
private readonly ILogger<ScheduleAddItemCommand> _logger;
private readonly string _serverUrl;
public ScheduleAddItemCommand(IConfiguration configuration, ILogger<ScheduleAddItemCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "schedule-name", Description = "The schedule name")]
public string ScheduleName { get; set; }
[CommandParameter(1, Name = "collection-name", Description = "The media collection name")]
public string CollectionName { get; set; }
// [CommandParameter(2, Description = "The collection playback order")]
// public PlaybackOrder Order { get; set; }
[CommandOption("start-type", 's', Description = "The playout start type")]
public StartType StartType { get; set; } = StartType.Dynamic;
[CommandOption("start-time", 't', Description = "The playout start time (of day)")]
public string StartTime { get; set; } = null;
[CommandOption("playout-mode", 'm', Description = "The playout mode")]
public PlayoutMode PlayoutMode { get; set; } = PlayoutMode.Flood;
[CommandOption(
"multiple-count",
'c',
Description = "How many items to play from the collection (for Multiple playout mode)")]
public int? MultipleCount { get; set; } = null;
[CommandOption(
"playout-duration",
'd',
Description = "How long to play items from the collection (for Duration playout mode)")]
public string PlayoutDuration { get; set; } = null;
[CommandOption(
"offline-tail",
'o',
Description =
"Whether to remain offline for the entire duration, or to start the next item immediately (for Duration playout mode)")]
public bool? OfflineTail { get; set; } = null;
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Option<ProgramScheduleViewModel> maybeSchedule = await GetSchedule(cancellationToken);
await maybeSchedule.Match(
programSchedule => AddItemToSchedule(cancellationToken, programSchedule),
() =>
{
_logger.LogError("Unable to locate schedule {Schedule}", ScheduleName);
return ValueTask.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError("Unable to add item to schedule: {Error}", ex.Message);
}
}
private async ValueTask AddItemToSchedule(
CancellationToken cancellationToken,
ProgramScheduleViewModel programSchedule)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
Option<MediaCollectionViewModel> maybeMediaCollection = await mediaCollectionsApi
.ApiMediaCollectionsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(mc => mc.Name == CollectionName));
await maybeMediaCollection.Match(
collection =>
AddScheduleItem(programSchedule.Id, collection.Id, cancellationToken),
() =>
{
_logger.LogError(
"Unable to locate collection {Collection}",
CollectionName);
return Task.CompletedTask;
});
}
private async Task<Option<ProgramScheduleViewModel>> GetSchedule(CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
return await programScheduleApi.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(schedule => schedule.Name == ScheduleName));
}
private async Task AddScheduleItem(
int programScheduleId,
int mediaCollectionId,
CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
var request = new AddProgramScheduleItem
{
ProgramScheduleId = programScheduleId,
StartType = StartType,
StartTime = StartTime,
PlayoutMode = PlayoutMode,
MediaCollectionId = mediaCollectionId,
PlayoutDuration = PlayoutDuration,
MultipleCount = MultipleCount,
OfflineTail = OfflineTail
};
await programScheduleApi.ApiSchedulesItemsAddPostAsync(request, cancellationToken);
_logger.LogInformation(
"Collection {Collection} has been added to schedule {Schedule}",
CollectionName,
ScheduleName);
}
}
}
@@ -1,86 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.Schedules
{
[Command("schedule create", Description = "Creates a new schedule")]
public class ScheduleCreateCommand : ICommand
{
private readonly ILogger<ScheduleCreateCommand> _logger;
private readonly string _serverUrl;
public ScheduleCreateCommand(IConfiguration configuration, ILogger<ScheduleCreateCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "schedule-name", Description = "The schedule name")]
public string Name { get; set; }
[CommandParameter(1, Name = "playback-order", Description = "The collection playback order")]
public PlaybackOrder Order { get; set; }
[CommandOption("reset", Description = "Resets the schedule to contain no items")]
public bool Reset { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await EnsureScheduleExistsAsync(cancellationToken);
result.IfLeft(error => _logger.LogError("Unable to create schedule: {Error}", error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to create schedule: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> EnsureScheduleExistsAsync(CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
Option<ProgramScheduleViewModel> maybeExisting = await programScheduleApi
.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(schedule => schedule.Name == Name));
await maybeExisting.Match(
existing =>
{
// TODO: update playback order if changed?
_logger.LogInformation("Schedule {Schedule} is already present", Name);
if (Reset)
{
return programScheduleApi
.ApiSchedulesProgramScheduleIdItemsDeleteAsync(existing.Id, cancellationToken)
.Iter(_ => _logger.LogInformation("Successfully reset schedule {Schedule}", Name));
}
return Task.CompletedTask;
},
() =>
{
var data = new CreateProgramSchedule(Name, Order);
return programScheduleApi.ApiSchedulesPostAsync(data, cancellationToken)
.Iter(_ => _logger.LogInformation("Successfully created schedule {Schedule}", Name));
});
return unit;
}
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace ErsatzTV.CommandLine
{
public class Config
{
public string ServerUrl { get; set; }
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace ErsatzTV.CommandLine
{
public enum DesiredResolution
{
W720H480 = 1,
W1280H720 = 2,
W1920H1080 = 3,
W3840H2160 = 4
}
}
@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<AssemblyName>ersatztv-cli</AssemblyName>
<LangVersion>9</LangVersion>
<PackageVersion>0.0.1</PackageVersion>
<AssemblyVersion>0.0.1</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="1.6.0" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
<PackageReference Include="RestSharp" Version="106.11.7" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\generated\ErsatzTV.Api.Sdk\src\ErsatzTV.Api.Sdk\ErsatzTV.Api.Sdk.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Hosting.Abstractions, Version=5.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<HintPath>..\..\..\..\..\..\usr\share\dotnet\packs\Microsoft.AspNetCore.App.Ref\5.0.0\ref\net5.0\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
-75
View File
@@ -1,75 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using Serilog.Events;
namespace ErsatzTV.CommandLine
{
public class Program
{
public static async Task<int> Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateLogger();
IHost host = CreateHostBuilder(args).Build();
try
{
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.UseTypeActivator(host.Services.GetService)
.Build()
.RunAsync(args);
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly");
return 1;
}
finally
{
Log.CloseAndFlush();
}
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices(
(_, services) =>
{
services.AddSingleton<IConsole, SystemConsole>();
IEnumerable<Type> typesThatImplementICommand = typeof(Program).Assembly.GetTypes()
.Where(x => typeof(ICommand).IsAssignableFrom(x))
.Where(x => !x.IsAbstract);
foreach (Type t in typesThatImplementICommand)
{
services.AddTransient(t);
}
})
.ConfigureAppConfiguration(
(_, configuration) =>
{
configuration.Sources.Clear();
string configFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ersatztv");
configuration.SetBasePath(configFolder);
configuration.AddJsonFile("cli.json", true, true);
})
.UseSerilog()
.UseConsoleLifetime();
}
}
@@ -1,24 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.1.0" /> <PackageReference Include="FluentAssertions" Version="6.3.0" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" /> <PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" /> <PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.0" />
<PackageReference Include="Serilog" Version="2.10.0" /> <PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" /> <PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" /> <PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
</ItemGroup> </ItemGroup>
@@ -19,7 +19,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{ {
var builder = new FFmpegComplexFilterBuilder(); var builder = new FFmpegComplexFilterBuilder();
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsNone.Should().BeTrue(); result.IsNone.Should().BeTrue();
} }
@@ -27,17 +27,38 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[Test] [Test]
public void Should_Return_Audio_Filter_With_AudioDuration() public void Should_Return_Audio_Filter_With_AudioDuration()
{ {
var duration = TimeSpan.FromMinutes(54); var duration = TimeSpan.FromMilliseconds(1000.1);
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration); .WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
filter => filter =>
{ {
filter.ComplexFilter.Should().Be($"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a]"); filter.ComplexFilter.Should().Be("[0:1]apad=whole_dur=1000.1ms[a]");
filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("0:0");
});
}
[Test]
// this needs to be a culture where '.' is a group separator
[SetCulture("it-IT")]
public void Should_Return_Audio_Filter_With_AudioDuration_Decimal()
{
var duration = TimeSpan.FromMilliseconds(1000.1);
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be("[0:1]apad=whole_dur=1000.1ms[a]");
filter.AudioLabel.Should().Be("[a]"); filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("0:0"); filter.VideoLabel.Should().Be("0:0");
}); });
@@ -51,7 +72,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
.WithAlignedAudio(duration) .WithAlignedAudio(duration)
.WithDeinterlace(true); .WithDeinterlace(true);
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -102,7 +123,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -253,11 +274,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
HorizontalMarginPercent = 7, HorizontalMarginPercent = 7,
VerticalMarginPercent = 5 VerticalMarginPercent = 5
}), }),
new Resolution { Width = 1920, Height = 1080 }) new Resolution { Width = 1920, Height = 1080 },
None)
.WithDeinterlace(deinterlace) .WithDeinterlace(deinterlace)
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None); .WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -275,37 +297,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]", "[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
false, false,
true, true,
"[0:0]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", "[0:0]deinterlace_qsv,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
true, true,
true, true,
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", "[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
false, false,
"[0:0]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]", "[0:0]scale_qsv=w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
false, false,
true, true,
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", "[0:0]setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
true, true,
"[0:0]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", "[0:0]scale_qsv=w=1920:h=1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")] "[v]")]
public void Should_Return_QSV_Video_Filter( public void Should_Return_QSV_Video_Filter(
bool deinterlace, bool deinterlace,
@@ -328,7 +350,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -346,37 +368,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]yadif_cuda,scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]", "[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
false, false,
true, true,
"[0:0]yadif_cuda,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]yadif_cuda,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
true, true,
true, true,
"[0:0]yadif_cuda,scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
false, false,
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]", "[0:0]scale_cuda=1920:1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
false, false,
true, true,
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
true, true,
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]scale_cuda=1920:1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
public void Should_Return_NVENC_Video_Filter( public void Should_Return_NVENC_Video_Filter(
bool deinterlace, bool deinterlace,
@@ -387,7 +409,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{ {
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Nvenc) .WithHardwareAcceleration(HardwareAccelerationKind.Nvenc)
.WithDeinterlace(deinterlace); .WithDeinterlace(deinterlace)
.WithInputPixelFormat("h264");
if (scale) if (scale)
{ {
@@ -399,7 +422,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -418,42 +441,42 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
true, true,
false, false,
true, true,
"[0:0]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]deinterlace_vaapi,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
true, true,
true, true,
true, true,
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
false, false,
true, true,
false, false,
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
false, false,
false, false,
true, true,
"[0:0]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
false, false,
true, true,
true, true,
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")] [TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
[TestCase( [TestCase(
@@ -461,28 +484,28 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
true, true,
false, false,
true, true,
"[0:0]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwupload,deinterlace_vaapi,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
true, true,
true, true,
true, true,
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
false, false,
true, true,
false, false,
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
@@ -496,7 +519,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false, false,
true, true,
true, true,
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
public void Should_Return_VAAPI_Video_Filter( public void Should_Return_VAAPI_Video_Filter(
string codec, string codec,
@@ -521,7 +544,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -12,7 +12,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime() public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
{ {
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137 #EXT-X-MEDIA-SEQUENCE:1137
@@ -26,13 +26,13 @@ live001137.ts
live001138.ts live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine); live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input); TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input);
result.PlaylistStart.Should().Be(start); result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137); result.Sequence.Should().Be(1137);
result.Playlist.Should().Be( result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U @"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
@@ -49,14 +49,14 @@ live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts live001139.ts
"); "));
} }
[Test] [Test]
public void HlsPlaylistFilter_ShouldLimitSegments() public void HlsPlaylistFilter_ShouldLimitSegments()
{ {
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137 #EXT-X-MEDIA-SEQUENCE:1137
@@ -70,13 +70,13 @@ live001137.ts
live001138.ts live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine); live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2); TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2);
result.PlaylistStart.Should().Be(start); result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137); result.Sequence.Should().Be(1137);
result.Playlist.Should().Be( result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U @"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
@@ -90,14 +90,14 @@ live001137.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts live001138.ts
"); "));
} }
[Test] [Test]
public void HlsPlaylistFilter_ShouldAddDiscontinuity() public void HlsPlaylistFilter_ShouldAddDiscontinuity()
{ {
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137 #EXT-X-MEDIA-SEQUENCE:1137
@@ -111,7 +111,7 @@ live001137.ts
live001138.ts live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine); live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(
start, start,
@@ -122,7 +122,7 @@ live001139.ts".Split(Environment.NewLine);
result.PlaylistStart.Should().Be(start); result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137); result.Sequence.Should().Be(1137);
result.Playlist.Should().Be( result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U @"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
@@ -140,14 +140,14 @@ live001138.ts
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts live001139.ts
#EXT-X-DISCONTINUITY #EXT-X-DISCONTINUITY
"); "));
} }
[Test] [Test]
public void HlsPlaylistFilter_ShouldFilterOldSegments() public void HlsPlaylistFilter_ShouldFilterOldSegments()
{ {
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137 #EXT-X-MEDIA-SEQUENCE:1137
@@ -161,13 +161,13 @@ live001137.ts
live001138.ts live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine); live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input); TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8)); result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139); result.Sequence.Should().Be(1139);
result.Playlist.Should().Be( result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U @"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
@@ -178,14 +178,14 @@ live001139.ts".Split(Environment.NewLine);
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts live001139.ts
"); "));
} }
[Test] [Test]
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity() public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
{ {
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137 #EXT-X-MEDIA-SEQUENCE:1137
@@ -200,13 +200,13 @@ live001137.ts
live001138.ts live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine); live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input); TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8)); result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139); result.Sequence.Should().Be(1139);
result.Playlist.Should().Be( result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U @"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
@@ -217,7 +217,15 @@ live001139.ts".Split(Environment.NewLine);
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts live001139.ts
"); "));
}
private static string NormalizeLineEndings(string str)
{
return str
.Replace("\r\n", "\n")
.Replace("\r", "\n")
.Replace("\n", Environment.NewLine);
} }
} }
} }
+30 -7
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
@@ -93,6 +94,17 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{ {
HardwareAccelerationKind.Vaapi HardwareAccelerationKind.Vaapi
}; };
public static string[] VideoToolboxCodecs =
{
"h264_videotoolbox",
"hevc_videotoolbox"
};
public static HardwareAccelerationKind[] VideoToolboxAcceleration =
{
HardwareAccelerationKind.VideoToolbox
};
} }
[Test, Combinatorial] [Test, Combinatorial]
@@ -103,21 +115,27 @@ namespace ErsatzTV.Core.Tests.FFmpeg
string inputPixelFormat, string inputPixelFormat,
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))] [ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
Resolution profileResolution, Resolution profileResolution,
[Values(true, false)]
bool pad,
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec, // [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration) // [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec, // [ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration) // [ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec, // [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration) // [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.VideoToolboxCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
{ {
string name = GetStringSha256Hash( string name = GetStringSha256Hash(
$"{inputCodec}_{inputPixelFormat}_{profileResolution}_{profileCodec}_{profileAcceleration}"); $"{inputCodec}_{inputPixelFormat}_{pad}_{profileResolution}_{profileCodec}_{profileAcceleration}");
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv"); string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
if (!File.Exists(file)) if (!File.Exists(file))
{ {
string resolution = pad ? "1920x1060" : "1920x1080";
var args = var args =
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size=1920x1080:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}"; $"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size={resolution}:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
var p1 = new Process var p1 = new Process
{ {
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
@@ -138,14 +156,15 @@ namespace ErsatzTV.Core.Tests.FFmpeg
new FFmpegPlaybackSettingsCalculator(), new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(), new FakeStreamSelector(),
new Mock<IImageCache>().Object, new Mock<IImageCache>().Object,
new Mock<ITempFilePool>().Object,
new Mock<ILogger<FFmpegProcessService>>().Object); new Mock<ILogger<FFmpegProcessService>>().Object);
MediaVersion v = new MediaVersion(); MediaVersion v = new MediaVersion();
var metadataRepository = new Mock<IMetadataRepository>(); var metadataRepository = new Mock<IMetadataRepository>();
metadataRepository metadataRepository
.Setup(r => r.UpdateLocalStatistics(It.IsAny<int>(), It.IsAny<MediaVersion>(), It.IsAny<bool>())) .Setup(r => r.UpdateLocalStatistics(It.IsAny<MediaItem>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<int, MediaVersion, bool>((_, version, _) => v = version); .Callback<MediaItem, MediaVersion, bool>((_, version, _) => v = version);
var localStatisticsProvider = new LocalStatisticsProvider( var localStatisticsProvider = new LocalStatisticsProvider(
metadataRepository.Object, metadataRepository.Object,
@@ -183,6 +202,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode = StreamingMode.TransportStream StreamingMode = StreamingMode.TransportStream
}, },
v, v,
v,
file,
file, file,
now, now,
now + TimeSpan.FromSeconds(5), now + TimeSpan.FromSeconds(5),
@@ -196,6 +217,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
TimeSpan.FromSeconds(5)); TimeSpan.FromSeconds(5));
process.StartInfo.RedirectStandardError = true; process.StartInfo.RedirectStandardError = true;
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
process.Start().Should().BeTrue(); process.Start().Should().BeTrue();
@@ -237,7 +260,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
return string.Empty; return string.Empty;
} }
using var sha = new System.Security.Cryptography.SHA256Managed(); using var sha = SHA256.Create();
byte[] textData = System.Text.Encoding.UTF8.GetBytes(text); byte[] textData = System.Text.Encoding.UTF8.GetBytes(text);
byte[] hash = sha.ComputeHash(textData); byte[] hash = sha.ComputeHash(textData);
return BitConverter.ToString(hash).Replace("-", string.Empty); return BitConverter.ToString(hash).Replace("-", string.Empty);
@@ -44,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Fakes
.IfNone(SystemTime.MinValueUtc); .IfNone(SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) => public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar)); _folders.Any(f => f.Path == libraryPath.Path);
public IEnumerable<string> ListSubdirectories(string folder) => public IEnumerable<string> ListSubdirectories(string folder) =>
_folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder); _folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder);
@@ -53,6 +53,7 @@ namespace ErsatzTV.Core.Tests.Fakes
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder); _files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public bool FileExists(string path) => _files.Any(f => f.Path == path); public bool FileExists(string path) => _files.Any(f => f.Path == path);
public bool FolderExists(string folder) => false;
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask(); public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
@@ -0,0 +1,211 @@
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Core.Jellyfin;
using FluentAssertions;
using LanguageExt;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Jellyfin
{
[TestFixture]
public class JellyfinPathReplacementServiceTests
{
[Test]
public async Task JellyfinWindows_To_EtvWindows()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"C:\Something\Some Shared Folder",
LocalPath = @"C:\Something Else\Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(true);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"C:\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"C:\Something Else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test]
public async Task JellyfinWindows_To_EtvLinux()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"C:\Something\Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"C:\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task JellyfinWindows_To_EtvLinux_UncPath()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"\\192.168.1.100\Something\Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task JellyfinWindows_To_EtvLinux_UncPathWithTrailingSlash()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"\\192.168.1.100\Something\Some Shared Folder\",
LocalPath = @"/mnt/something else/Some Shared Folder/",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task JellyfinLinux_To_EtvWindows()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"/mnt/something/Some Shared Folder",
LocalPath = @"C:\Something Else\Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Linux" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(true);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"/mnt/something/Some Shared Folder/Some Movie/Some Movie.mkv");
result.Should().Be(@"C:\Something Else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test]
public async Task JellyfinLinux_To_EtvLinux()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"/mnt/something/Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Linux" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"/mnt/something/Some Shared Folder/Some Movie/Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
}
}
@@ -25,7 +25,7 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<ILogger<LocalStatisticsProvider>>().Object); new Mock<ILogger<LocalStatisticsProvider>>().Object);
var input = new LocalStatisticsProvider.FFprobe( var input = new LocalStatisticsProvider.FFprobe(
new LocalStatisticsProvider.FFprobeFormat("123.45"), new LocalStatisticsProvider.FFprobeFormat("123.45", null),
new List<LocalStatisticsProvider.FFprobeStream>(), new List<LocalStatisticsProvider.FFprobeStream>(),
new List<LocalStatisticsProvider.FFprobeChapter>()); new List<LocalStatisticsProvider.FFprobeChapter>());
@@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@@ -52,6 +53,10 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>())) _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string>().AsEnumerable().AsTask()); .Returns(new List<string>().AsEnumerable().AsTask());
_mediaItemRepository = new Mock<IMediaItemRepository>();
_mediaItemRepository.Setup(x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()))
.Returns(new List<int>().AsTask());
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>(); _localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
_localMetadataProvider = new Mock<ILocalMetadataProvider>(); _localMetadataProvider = new Mock<ILocalMetadataProvider>();
@@ -71,6 +76,7 @@ namespace ErsatzTV.Core.Tests.Metadata
} }
private Mock<IMovieRepository> _movieRepository; private Mock<IMovieRepository> _movieRepository;
private Mock<IMediaItemRepository> _mediaItemRepository;
private Mock<ILocalStatisticsProvider> _localStatisticsProvider; private Mock<ILocalStatisticsProvider> _localStatisticsProvider;
private Mock<ILocalMetadataProvider> _localMetadataProvider; private Mock<ILocalMetadataProvider> _localMetadataProvider;
private Mock<IImageCache> _imageCache; private Mock<IImageCache> _imageCache;
@@ -533,6 +539,9 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test] [Test]
public async Task RenamedMovie_Should_Delete_Old_Movie() public async Task RenamedMovie_Should_Delete_Old_Movie()
{ {
// TODO: handle this case more elegantly
// ideally, detect that the movie was renamed and still delete the old one (or update the path?)
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)"); string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi"); string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@@ -555,12 +564,14 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once); _mediaItemRepository.Verify(
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once); x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
} }
[Test] [Test]
public async Task DeletedMovieAndFolder_Should_Delete_Old_Movie() public async Task DeletedMovieAndFolder_Should_Flag_File_Not_Found()
{ {
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)"); string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi"); string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@@ -568,10 +579,8 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>())) _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string> { oldMoviePath }.AsEnumerable().AsTask()); .Returns(new List<string> { oldMoviePath }.AsEnumerable().AsTask());
string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv");
MovieFolderScanner service = GetService( MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now } new FakeFolderEntry(FakeRoot)
); );
var libraryPath = new LibraryPath var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() }; { Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
@@ -584,11 +593,12 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once); _mediaItemRepository.Verify(
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once); x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
} }
private MovieFolderScanner GetService(params FakeFileEntry[] files) => private MovieFolderScanner GetService(params FakeFileEntry[] files) =>
new( new(
new FakeLocalFileSystem(new List<FakeFileEntry>(files)), new FakeLocalFileSystem(new List<FakeFileEntry>(files)),
@@ -600,7 +610,28 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<ISearchIndex>().Object, new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object, new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object, new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object, new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object
);
private MovieFolderScanner GetService(params FakeFolderEntry[] folders) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(), new List<FakeFolderEntry>(folders)),
_movieRepository.Object,
_localStatisticsProvider.Object,
_localMetadataProvider.Object,
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object new Mock<ILogger<MovieFolderScanner>>().Object
); );
} }
@@ -46,6 +46,69 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemIndex.Should().Be(1);
enumerator.State.Index.Should().Be(1);
playoutItems.Count.Should().Be(3);
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
}
[Test]
public void Should_Fill_Exact_Duration_CustomTitle()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
var scheduleItem = new ProgramScheduleItemDuration
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
TailMode = TailMode.None,
PlaybackOrder = PlaybackOrder.Chronological,
CustomTitle = "Custom Title"
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var scheduler = new PlayoutModeSchedulerDuration(new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
StartState,
CollectionEnumerators(scheduleItem, enumerator),
scheduleItem,
NextScheduleItem,
HardStop);
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(2);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
@@ -61,16 +124,22 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime); playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1); playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None); playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
playoutItems[0].CustomTitle.Should().Be("Custom Title");
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(1);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
playoutItems[1].CustomTitle.Should().Be("Custom Title");
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2)); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(1);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
playoutItems[2].CustomTitle.Should().Be("Custom Title");
} }
[Test] [Test]
@@ -105,7 +174,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -120,16 +189,19 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime); playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1); playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None); playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
} }
[Test] [Test]
@@ -165,7 +237,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutItems.Last().FinishOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -180,16 +252,19 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime); playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1); playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None); playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
} }
[Test] [Test]
@@ -235,7 +310,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -251,21 +326,25 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime); playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1); playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None); playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
playoutItems[3].MediaItemId.Should().Be(3); playoutItems[3].MediaItemId.Should().Be(3);
playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutItems[3].GuideGroup.Should().Be(1); playoutItems[3].GuideGroup.Should().Be(3);
playoutItems[3].FillerKind.Should().Be(FillerKind.Fallback); playoutItems[3].FillerKind.Should().Be(FillerKind.Fallback);
playoutItems[3].GuideFinish.HasValue.Should().BeFalse();
} }
[Test] [Test]
@@ -311,7 +390,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -327,31 +406,37 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime); playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1); playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None); playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
playoutItems[3].MediaItemId.Should().Be(3); playoutItems[3].MediaItemId.Should().Be(3);
playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutItems[3].GuideGroup.Should().Be(1); playoutItems[3].GuideGroup.Should().Be(3);
playoutItems[3].FillerKind.Should().Be(FillerKind.Tail); playoutItems[3].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[3].GuideFinish.HasValue.Should().BeFalse();
playoutItems[4].MediaItemId.Should().Be(4); playoutItems[4].MediaItemId.Should().Be(4);
playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 50, 0))); playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 50, 0)));
playoutItems[4].GuideGroup.Should().Be(1); playoutItems[4].GuideGroup.Should().Be(3);
playoutItems[4].FillerKind.Should().Be(FillerKind.Tail); playoutItems[4].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[3].GuideFinish.HasValue.Should().BeFalse();
playoutItems[5].MediaItemId.Should().Be(3); playoutItems[5].MediaItemId.Should().Be(3);
playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 55, 0))); playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 55, 0)));
playoutItems[5].GuideGroup.Should().Be(1); playoutItems[5].GuideGroup.Should().Be(3);
playoutItems[5].FillerKind.Should().Be(FillerKind.Tail); playoutItems[5].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[3].GuideFinish.HasValue.Should().BeFalse();
} }
[Test] [Test]
@@ -397,7 +482,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 57, 0))); playoutItems.Last().FinishOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -413,31 +498,37 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime); playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1); playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None); playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
playoutItems[3].MediaItemId.Should().Be(3); playoutItems[3].MediaItemId.Should().Be(3);
playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutItems[3].GuideGroup.Should().Be(1); playoutItems[3].GuideGroup.Should().Be(3);
playoutItems[3].FillerKind.Should().Be(FillerKind.Tail); playoutItems[3].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[3].GuideFinish.HasValue.Should().BeFalse();
playoutItems[4].MediaItemId.Should().Be(4); playoutItems[4].MediaItemId.Should().Be(4);
playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 49, 0))); playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 49, 0)));
playoutItems[4].GuideGroup.Should().Be(1); playoutItems[4].GuideGroup.Should().Be(3);
playoutItems[4].FillerKind.Should().Be(FillerKind.Tail); playoutItems[4].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[4].GuideFinish.HasValue.Should().BeFalse();
playoutItems[5].MediaItemId.Should().Be(3); playoutItems[5].MediaItemId.Should().Be(3);
playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 53, 0))); playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 53, 0)));
playoutItems[5].GuideGroup.Should().Be(1); playoutItems[5].GuideGroup.Should().Be(3);
playoutItems[5].FillerKind.Should().Be(FillerKind.Tail); playoutItems[5].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[5].GuideFinish.HasValue.Should().BeFalse();
} }
[Test] [Test]
@@ -500,7 +591,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -517,36 +608,43 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime); playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1); playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None); playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
playoutItems[3].MediaItemId.Should().Be(3); playoutItems[3].MediaItemId.Should().Be(3);
playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutItems[3].GuideGroup.Should().Be(1); playoutItems[3].GuideGroup.Should().Be(3);
playoutItems[3].FillerKind.Should().Be(FillerKind.Tail); playoutItems[3].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[3].GuideFinish.HasValue.Should().BeFalse();
playoutItems[4].MediaItemId.Should().Be(4); playoutItems[4].MediaItemId.Should().Be(4);
playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 49, 0))); playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 49, 0)));
playoutItems[4].GuideGroup.Should().Be(1); playoutItems[4].GuideGroup.Should().Be(3);
playoutItems[4].FillerKind.Should().Be(FillerKind.Tail); playoutItems[4].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[4].GuideFinish.HasValue.Should().BeFalse();
playoutItems[5].MediaItemId.Should().Be(3); playoutItems[5].MediaItemId.Should().Be(3);
playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 53, 0))); playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 53, 0)));
playoutItems[5].GuideGroup.Should().Be(1); playoutItems[5].GuideGroup.Should().Be(3);
playoutItems[5].FillerKind.Should().Be(FillerKind.Tail); playoutItems[5].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[5].GuideFinish.HasValue.Should().BeFalse();
playoutItems[6].MediaItemId.Should().Be(5); playoutItems[6].MediaItemId.Should().Be(5);
playoutItems[6].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 57, 0))); playoutItems[6].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
playoutItems[6].GuideGroup.Should().Be(1); playoutItems[6].GuideGroup.Should().Be(3);
playoutItems[6].FillerKind.Should().Be(FillerKind.Fallback); playoutItems[6].FillerKind.Should().Be(FillerKind.Fallback);
playoutItems[6].GuideFinish.HasValue.Should().BeFalse();
} }
} }
} }
@@ -73,6 +73,75 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[1].GuideGroup.Should().Be(2); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
}
[Test]
public void Should_Fill_Exactly_To_Next_Schedule_Item_Flood()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
var scheduleItem = new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var sortedScheduleItems = new List<ProgramScheduleItem>
{
scheduleItem,
// this caused trouble with the peek logic and the IsFlood flag
new ProgramScheduleItemFlood
{
StartTime = TimeSpan.FromHours(3),
}
};
var scheduler = new PlayoutModeSchedulerFlood(sortedScheduleItems, new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
StartState,
CollectionEnumerators(scheduleItem, enumerator),
scheduleItem,
NextScheduleItem,
HardStop);
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemIndex.Should().Be(1);
enumerator.State.Index.Should().Be(1);
playoutItems.Count.Should().Be(3);
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2)); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(3); playoutItems[2].GuideGroup.Should().Be(3);
@@ -709,7 +778,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne
{ {
StartTime = TimeSpan.FromHours(3) StartTime = TimeSpan.FromHours(3),
}; };
} }
} }
@@ -54,7 +54,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -72,12 +72,12 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2)); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
} }
@@ -119,7 +119,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -137,12 +137,12 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
} }
@@ -195,7 +195,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -214,27 +214,27 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[3].MediaItemId.Should().Be(3); playoutItems[3].MediaItemId.Should().Be(3);
playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutItems[3].GuideGroup.Should().Be(1); playoutItems[3].GuideGroup.Should().Be(3);
playoutItems[3].FillerKind.Should().Be(FillerKind.Tail); playoutItems[3].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[4].MediaItemId.Should().Be(4); playoutItems[4].MediaItemId.Should().Be(4);
playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 50, 0))); playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 50, 0)));
playoutItems[4].GuideGroup.Should().Be(1); playoutItems[4].GuideGroup.Should().Be(3);
playoutItems[4].FillerKind.Should().Be(FillerKind.Tail); playoutItems[4].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[5].MediaItemId.Should().Be(3); playoutItems[5].MediaItemId.Should().Be(3);
playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 55, 0))); playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 55, 0)));
playoutItems[5].GuideGroup.Should().Be(1); playoutItems[5].GuideGroup.Should().Be(3);
playoutItems[5].FillerKind.Should().Be(FillerKind.Tail); playoutItems[5].FillerKind.Should().Be(FillerKind.Tail);
} }
@@ -287,7 +287,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -306,17 +306,17 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[3].MediaItemId.Should().Be(3); playoutItems[3].MediaItemId.Should().Be(3);
playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutItems[3].GuideGroup.Should().Be(1); playoutItems[3].GuideGroup.Should().Be(3);
playoutItems[3].FillerKind.Should().Be(FillerKind.Fallback); playoutItems[3].FillerKind.Should().Be(FillerKind.Fallback);
} }
@@ -369,7 +369,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 57, 0))); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -388,27 +388,27 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[3].MediaItemId.Should().Be(3); playoutItems[3].MediaItemId.Should().Be(3);
playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutItems[3].GuideGroup.Should().Be(1); playoutItems[3].GuideGroup.Should().Be(3);
playoutItems[3].FillerKind.Should().Be(FillerKind.Tail); playoutItems[3].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[4].MediaItemId.Should().Be(4); playoutItems[4].MediaItemId.Should().Be(4);
playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 49, 0))); playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 49, 0)));
playoutItems[4].GuideGroup.Should().Be(1); playoutItems[4].GuideGroup.Should().Be(3);
playoutItems[4].FillerKind.Should().Be(FillerKind.Tail); playoutItems[4].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[5].MediaItemId.Should().Be(3); playoutItems[5].MediaItemId.Should().Be(3);
playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 53, 0))); playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 53, 0)));
playoutItems[5].GuideGroup.Should().Be(1); playoutItems[5].GuideGroup.Should().Be(3);
playoutItems[5].FillerKind.Should().Be(FillerKind.Tail); playoutItems[5].FillerKind.Should().Be(FillerKind.Tail);
} }
@@ -478,7 +478,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -498,32 +498,32 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddMinutes(55));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0))); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(1, 50, 0)));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[3].MediaItemId.Should().Be(3); playoutItems[3].MediaItemId.Should().Be(3);
playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0))); playoutItems[3].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
playoutItems[3].GuideGroup.Should().Be(1); playoutItems[3].GuideGroup.Should().Be(3);
playoutItems[3].FillerKind.Should().Be(FillerKind.Tail); playoutItems[3].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[4].MediaItemId.Should().Be(4); playoutItems[4].MediaItemId.Should().Be(4);
playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 49, 0))); playoutItems[4].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 49, 0)));
playoutItems[4].GuideGroup.Should().Be(1); playoutItems[4].GuideGroup.Should().Be(3);
playoutItems[4].FillerKind.Should().Be(FillerKind.Tail); playoutItems[4].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[5].MediaItemId.Should().Be(3); playoutItems[5].MediaItemId.Should().Be(3);
playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 53, 0))); playoutItems[5].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 53, 0)));
playoutItems[5].GuideGroup.Should().Be(1); playoutItems[5].GuideGroup.Should().Be(3);
playoutItems[5].FillerKind.Should().Be(FillerKind.Tail); playoutItems[5].FillerKind.Should().Be(FillerKind.Tail);
playoutItems[6].MediaItemId.Should().Be(5); playoutItems[6].MediaItemId.Should().Be(5);
playoutItems[6].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 57, 0))); playoutItems[6].StartOffset.Should().Be(StartState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
playoutItems[6].GuideGroup.Should().Be(1); playoutItems[6].GuideGroup.Should().Be(3);
playoutItems[6].FillerKind.Should().Be(FillerKind.Fallback); playoutItems[6].FillerKind.Should().Be(FillerKind.Fallback);
} }
@@ -593,7 +593,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3)); playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime); playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2); playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue(); playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse(); playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue(); playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
@@ -613,12 +613,12 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[1].MediaItemId.Should().Be(2); playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1)); playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(1); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2)); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(1); playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None); playoutItems[2].FillerKind.Should().Be(FillerKind.None);
} }
@@ -63,6 +63,77 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[0].FillerKind.Should().Be(FillerKind.None); playoutItems[0].FillerKind.Should().Be(FillerKind.None);
} }
[Test]
public void Should_Have_Gap_With_Empty_Tail_Empty_Fallback()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
var collectionTwo = new Collection { Id = 2, Name = "Collection 2", MediaItems = new List<MediaItem>() };
var collectionThree = new Collection { Id = 3, Name = "Collection 3", MediaItems = new List<MediaItem>() };
var scheduleItem = new ProgramScheduleItemOne
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = new FillerPreset
{
FillerKind = FillerKind.Tail,
CollectionId = collectionTwo.Id,
Collection = collectionTwo
},
FallbackFiller = new FillerPreset
{
FillerKind = FillerKind.Fallback,
CollectionId = collectionThree.Id,
Collection = collectionThree
}
};
var enumerator1 = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var enumerator2 = new ChronologicalMediaCollectionEnumerator(
collectionTwo.MediaItems,
new CollectionEnumeratorState());
var enumerator3 = new ChronologicalMediaCollectionEnumerator(
collectionThree.MediaItems,
new CollectionEnumeratorState());
var scheduler = new PlayoutModeSchedulerOne(new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
StartState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2, scheduleItem.FallbackFiller, enumerator3),
scheduleItem,
NextScheduleItem,
HardStop);
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(1));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemIndex.Should().Be(1);
enumerator1.State.Index.Should().Be(1);
enumerator2.State.Index.Should().Be(0);
enumerator3.State.Index.Should().Be(0);
playoutItems.Count.Should().Be(1);
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
}
[Test] [Test]
public void Should_Not_Have_Gap_With_Exact_Tail() public void Should_Not_Have_Gap_With_Exact_Tail()
{ {

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