Compare commits

..
Author SHA1 Message Date
Jason Dove 96bc2c28f2 update changelog for release 56 [no ci] 2021-09-10 13:59:14 -05:00
Jason DoveandGitHub a076b3eb30 add shuffle-in-order support to all collections (#356) 2021-09-10 13:33:04 -05:00
Jason DoveandGitHub fc360602ad add smart collections (#355)
* start to add smart collections

* add smart collection table; delete smart collection

* overwrite smart collections

* support scheduling smart collections

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

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

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

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

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

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

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

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

* create multi collection with no items

* edit multi collections

* fix plex credentials threading issue

* add playback order to multi collection items

* group episodes outside of shuffled enumerator

* move playback order onto each schedule item

* fix multi collection grouping

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: Blazored.LocalStorage
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-23 13:06:30 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
de3e2ea754 Bump NUnit3TestAdapter from 3.17.0 to 4.0.0 (#283)
Bumps [NUnit3TestAdapter](https://github.com/nunit/nunit3-vs-adapter) from 3.17.0 to 4.0.0.
- [Release notes](https://github.com/nunit/nunit3-vs-adapter/releases)
- [Commits](https://github.com/nunit/nunit3-vs-adapter/compare/V3.17...V4.0.0)

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

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

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

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

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

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

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

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

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

* update changelog

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

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-22 06:28:01 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d5a0951a9b Bump FluentValidation from 10.1.0 to 10.2.3 (#275)
Bumps [FluentValidation](https://github.com/JeremySkinner/fluentvalidation) from 10.1.0 to 10.2.3.
- [Release notes](https://github.com/JeremySkinner/fluentvalidation/releases)
- [Changelog](https://github.com/FluentValidation/FluentValidation/blob/main/Changelog.txt)
- [Commits](https://github.com/JeremySkinner/fluentvalidation/compare/10.1.0...10.2.3)

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

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

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

* emby and jellyfin respect library refresh interval

* support 10-bit content with nvidia acceleration
2021-06-19 21:25:44 -05:00
224 changed files with 38875 additions and 746 deletions
+8
View File
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: daily
assignees:
- jasongdove
+71
View File
@@ -0,0 +1,71 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '30 3 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'csharp' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
+6 -5
View File
@@ -11,6 +11,9 @@ jobs:
- os: ubuntu-latest
kind: linux
target: linux-x64
- os: ubuntu-latest
kind: linux
target: linux-arm
- os: windows-latest
kind: windows
target: win-x64
@@ -39,24 +42,22 @@ jobs:
# Define some variables for things we need
tag=$(git describe --tags --abbrev=0)
release_name="ErsatzTV-$tag-${{ matrix.target }}"
#release_name_cli="ErsatzTV.CommandLine-$tag-${{ matrix.target }}"
# 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.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli" /property:InformationalVersion="${tag:1}-${{ matrix.target }}"
# Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*"
#7z a -tzip "${release_name_cli}.zip" "./${release_name_cli}/*"
elif [ "${{ matrix.target }}" == "linux-arm" ]; then
cp lib/linux-arm/* "$release_name/"
tar czvf "${release_name}.tar.gz" "$release_name"
else
tar czvf "${release_name}.tar.gz" "$release_name"
#tar czvf "${release_name_cli}.tar.gz" "$release_name_cli"
fi
# Delete output directory
rm -r "$release_name"
#rm -r "$release_name_cli"
- name: Publish
uses: softprops/action-gh-release@v1
+111 -1
View File
@@ -5,6 +5,107 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.0.56-alpha] - 2021-09-10
### Added
- Add Smart Collections
- Smart Collections use search queries and can be created from the search result page
- Smart Collections are re-evaluated every time playouts are extended or rebuilt to automatically include newly-matching items
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
- Allow `Shuffle In Order` with Collections and Smart Collections
- Episodes will be grouped by show, and music videos will be grouped by artist
- All movies will be a single group (multi-collections are probably better if `Shuffle In Order` is desired for movies)
- All groups will be be ordered chronologically (custom ordering is only supported in multi-collections)
### Fixed
- Generate XMLTV that validates successfully
- Properly order elements
- Omit channels with no programmes
- Properly identify channels using the format number.etv like `15.etv`
- Fix building playouts when multi-part episode grouping is enabled and episodes are missing metadata
- Fix incorrect total items count in `Multi Collections` table
## [0.0.55-alpha] - 2021-09-03
### Fixed
- Fix all local library scanners to ignore dot underscore files (`._`)
## [0.0.54-alpha] - 2021-08-21
### Added
- Add `Shuffle In Order` playback order for multi-collections.
- This is useful for randomizing multiple collections/shows on a single channel, while each collection maintains proper ordering (custom or chronological)
### Fixed
- Fix bug parsing ffprobe output in cultures where `.` is a group/thousands separator
- This bug likely prevented ETV from scheduling correctly or working at all in those cultures
- After installing a version with this fix, affected content will need to be removed from ETV and re-added
## [0.0.53-alpha] - 2021-08-01
### Fixed
- Fix error message displayed after building empty playout
- Fix Emby and Jellyfin links
### Changed
- Always proxy Jellyfin and Emby artwork; this fixes artwork in some networking scenarios
## [0.0.52-alpha] - 2021-07-22
### Added
- Add multiple local libraries to better organize your media
- Add `Move Library Path` function to support reorganizing existing local libraries
### Fixed
- Fix bug preventing playouts from rebuilding after an empty collection is encountered within a multi-collection
## [0.0.51-alpha] - 2021-07-18
### Added
- Add `Multi Collection` to support shuffling multiple collections within a single schedule item
- Collections within a multi collection are optionally grouped together and ordered when scheduling; this can be useful for franchises
- Add `Playout Days To Build` setting to control how many days of playout data/program guide data should be built into the future
### Changed
- Move `Playback Order` from schedule to schedule items
- This allows different schedule items to have different playback orders within a single schedule
### Fixed
- Fix release notes on home page with `-alpha` suffix
- Fix linux-arm release by including SQLite interop artifacts
- Fix issue where cached Plex credentials may become invalid when multiple servers are used
## [0.0.50-alpha] - 2021-07-13
### Added
- Add Linux ARM release artifacts which can be used on Raspberry Pi devices
### Fixed
- Fix bug preventing ingestion of local movies with fallback metadata (without NFO files)
- Fix extra spaces in titles of local movies with fallback metadata (without NFO files)
## [0.0.49-prealpha] - 2021-07-11
### Added
- Include audio language metadata in all streaming modes
- Add special zero-count case to `Multiple` playout mode
- This configuration will automatically maintain the multiple count so that it is equal to the number of items in the collection
- This configuration should be used if you want to play every item in a collection exactly once before advancing
### Changed
- Use case-insensitive sorting for collections page and `Add to Collection` dialog
- Use case-insensitive sorting for all collection lists in schedule items editor
- Use natural sorting for schedules page and `Add to Schedule` dialog
### Fixed
- Fix flooding schedule items that have a fixed start time
## [0.0.48-prealpha] - 2021-06-22
### Added
- Store pixel format with media statistics; this is needed to support normalization of 10-bit media items
- This requires re-ingesting statistics for all media items the first time this version is launched
### Changed
- Use ffprobe to retrieve statistics for Plex media items (Local, Emby and Jellyfin libraries already use ffprobe)
### Fixed
- Fix playback of transcoded 10-bit media items (pixel format `yuv420p10le`) on Nvidia hardware
- Emby and Jellyfin scanners now respect library refresh interval setting
- Fix adding new seasons to existing Emby and Jellyfin shows
- Fix adding new episodes to existing Emby and Jellyfin seasons
## [0.0.47-prealpha] - 2021-06-15
### Added
- Add warning during playout rebuild when schedule has been emptied
@@ -468,7 +569,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.47-prealpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...HEAD
[0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
[0.0.54-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
[0.0.53-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.52-alpha...v0.0.53-alpha
[0.0.52-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.51-alpha...v0.0.52-alpha
[0.0.51-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.50-alpha...v0.0.51-alpha
[0.0.50-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.49-prealpha...v0.0.50-alpha
[0.0.49-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.48-prealpha...v0.0.49-prealpha
[0.0.48-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.47-prealpha...v0.0.48-prealpha
[0.0.47-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.46-prealpha...v0.0.47-prealpha
[0.0.46-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.45-prealpha...v0.0.46-prealpha
[0.0.45-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Configuration.Commands
{
public record UpdatePlayoutDaysToBuild(int DaysToBuild) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Configuration.Commands
{
public class
UpdatePlayoutDaysToBuildHandler : MediatR.IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdatePlayoutDaysToBuildHandler(
IConfigElementRepository configElementRepository,
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
_configElementRepository = configElementRepository;
_dbContextFactory = dbContextFactory;
_workerChannel = workerChannel;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdatePlayoutDaysToBuild request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Unit> validation = await Validate(request);
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.DaysToBuild));
}
private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild)
{
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild);
// build all playouts to proper number of days
List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Channel)
.ToListAsync();
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
{
await _workerChannel.WriteAsync(new BuildPlayout(playoutId));
}
return Unit.Default;
}
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) =>
Optional(request.DaysToBuild)
.Filter(days => days > 0)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Days to build must be greater than zero")
.AsTask();
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Configuration.Queries
{
public record GetPlayoutDaysToBuild : IRequest<int>;
}
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Configuration.Queries
{
public class GetPlayoutDaysToBuildHandler : IRequestHandler<GetPlayoutDaysToBuild, int>
{
private readonly IConfigElementRepository _configElementRepository;
public GetPlayoutDaysToBuildHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<int> Handle(GetPlayoutDaysToBuild request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
.Map(result => result.IfNone(2));
}
}
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Emby.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
switch (parameters.Library.MediaKind)
{
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Emby.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate(
ISynchronizeEmbyLibraryById request) =>
(await ValidateConnection(request), await EmbyLibraryMustExist(request), await ValidateFFprobePath())
(await ValidateConnection(request), await EmbyLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply(
(connectionParameters, embyLibrary, ffprobePath) => new RequestParameters(
(connectionParameters, embyLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters,
embyLibrary,
request.ForceScan,
libraryRefreshInterval,
ffprobePath
));
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Emby.Commands
_mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId)
.Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist."));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Emby.Commands
ConnectionParameters ConnectionParameters,
EmbyLibrary Library,
bool ForceScan,
int LibraryRefreshInterval,
string FFprobePath);
private record ConnectionParameters(
@@ -13,7 +13,7 @@
</PackageReference>
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
switch (parameters.Library.MediaKind)
{
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate(
ISynchronizeJellyfinLibraryById request) =>
(await ValidateConnection(request), await JellyfinLibraryMustExist(request), await ValidateFFprobePath())
(await ValidateConnection(request), await JellyfinLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply(
(connectionParameters, jellyfinLibrary, ffprobePath) => new RequestParameters(
(connectionParameters, jellyfinLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters,
jellyfinLibrary,
request.ForceScan,
libraryRefreshInterval,
ffprobePath
));
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Jellyfin.Commands
_mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId)
.Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} does not exist."));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
ConnectionParameters ConnectionParameters,
JellyfinLibrary Library,
bool ForceScan,
int LibraryRefreshInterval,
string FFprobePath);
private record ConnectionParameters(
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Libraries.Commands
{
public record CreateLocalLibrary(string Name, LibraryMediaKind MediaKind, List<string> Paths)
: ILocalLibraryRequest, IRequest<Either<BaseError, LocalLibraryViewModel>>;
}
@@ -0,0 +1,84 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Libraries.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Libraries.Commands
{
public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
IRequestHandler<CreateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IEntityLocker _entityLocker;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IEntityLocker entityLocker,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_entityLocker = entityLocker;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
CreateLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
}
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
TvContext dbContext,
LocalLibrary localLibrary)
{
await dbContext.LocalLibraries.AddAsync(localLibrary);
await dbContext.SaveChangesAsync();
if (_entityLocker.LockLibrary(localLibrary.Id))
{
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
}
return ProjectToViewModel(localLibrary);
}
private static Task<Validation<BaseError, LocalLibrary>> Validate(
TvContext dbContext,
CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
TvContext dbContext,
CreateLocalLibrary request) =>
dbContext.LocalMediaSources
.OrderBy(lms => lms.Id)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(
lms => new LocalLibrary
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Path = p }).ToList(),
MediaKind = request.MediaKind,
MediaSourceId = lms.Id
})
.Map(o => o.ToValidation<BaseError>("LocalMediaSource does not exist."));
}
}
@@ -19,8 +19,8 @@ namespace ErsatzTV.Application.Libraries.Commands
{
private readonly ILibraryRepository _libraryRepository;
public CreateLocalLibraryPathHandler(ILibraryRepository mediaSourceRepository) =>
_libraryRepository = mediaSourceRepository;
public CreateLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
_libraryRepository = libraryRepository;
public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle(
CreateLocalLibraryPath request,
@@ -45,7 +45,6 @@ namespace ErsatzTV.Application.Libraries.Commands
List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId)
.Map(list => list.Map(c => c.Path).ToList());
return Optional(request.Path)
.Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Path must not belong to another library path");
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Libraries.Commands
{
public record DeleteLocalLibrary(int LocalLibraryId) : IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Libraries.Commands
{
public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
IRequestHandler<DeleteLocalLibrary, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IDbConnection _dbConnection;
private readonly ISearchIndex _searchIndex;
public DeleteLocalLibraryHandler(
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection,
ISearchIndex searchIndex)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, LocalLibrary> validation = await LocalLibraryMustExist(dbContext, request);
return await validation.Apply(localLibrary => DoDeletion(dbContext, localLibrary));
}
private async Task<Unit> DoDeletion(TvContext dbContext, LocalLibrary localLibrary)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT MediaItem.Id FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = localLibrary.Id })
.Map(result => result.ToList());
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
dbContext.LocalLibraries.Remove(localLibrary);
await dbContext.SaveChangesAsync();
return Unit.Default;
}
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
TvContext dbContext,
DeleteLocalLibrary request) =>
dbContext.LocalLibraries
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.LocalLibraryId)
.Map(o => o.ToValidation<BaseError>($"Local library {request.LocalLibraryId} does not exist."));
}
}
@@ -1,7 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Libraries.Commands
{
public record DeleteLocalLibraryPath(int LocalLibraryPathId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -1,46 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Libraries.Commands
{
public class
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
{
private readonly ILibraryRepository _libraryRepository;
private readonly ISearchIndex _searchIndex;
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
{
_libraryRepository = libraryRepository;
_searchIndex = searchIndex;
}
public Task<Either<BaseError, Unit>> Handle(
DeleteLocalLibraryPath request,
CancellationToken cancellationToken) =>
MediaSourceMustExist(request)
.MapT(DoDeletion)
.Bind(t => t.ToEitherAsync());
private async Task<Unit> DoDeletion(LibraryPath libraryPath)
{
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
.HeadOrNone()
.ToValidation<BaseError>(
$"Local library path {request.LocalLibraryPathId} does not exist.");
}
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Application.Libraries.Commands
{
public interface ILocalLibraryRequest
{
public string Name { get; }
}
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Libraries.Commands
{
public abstract class LocalLibraryHandlerBase
{
protected static Task<Validation<BaseError, LocalLibrary>> NameMustBeValid(
ILocalLibraryRequest request,
LocalLibrary localLibrary) =>
request.NotEmpty(c => c.Name)
.Bind(_ => request.NotLongerThan(50)(c => c.Name))
.Map(_ => localLibrary).AsTask();
protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid(
TvContext dbContext,
LocalLibrary localLibrary,
int? existingLibraryId = null)
{
List<string> allPaths = await dbContext.LocalLibraries
.Include(ll => ll.Paths)
.Filter(ll => existingLibraryId == null || ll.Id != existingLibraryId)
.ToListAsync()
.Map(list => list.SelectMany(ll => ll.Paths).Map(lp => lp.Path).ToList());
return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path))))
.Filter(length => length == 0)
.Map(_ => localLibrary)
.ToValidation<BaseError>("Path must not belong to another library path");
}
private static bool AreSubPaths(string path1, string path2)
{
string one = path1 + Path.DirectorySeparatorChar;
string two = path2 + Path.DirectorySeparatorChar;
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Libraries.Commands
{
public record MoveLocalLibraryPath(int LibraryPathId, int TargetLibraryId) : IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Libraries.Commands
{
public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath, Either<BaseError, Unit>>
{
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IDbConnection _dbConnection;
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
public MoveLocalLibraryPathHandler(
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection,
ILogger<MoveLocalLibraryPathHandler> logger)
{
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> Handle(
MoveLocalLibraryPath request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => MovePath(dbContext, parameters));
}
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
{
LibraryPath path = parameters.LibraryPath;
LocalLibrary newLibrary = parameters.Library;
path.LibraryId = newLibrary.Id;
if (await dbContext.SaveChangesAsync() > 0)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT MediaItem.Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
new { LibraryPathId = path.Id })
.Map(result => result.ToList());
foreach (int id in ids)
{
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id);
foreach (MediaItem mediaItem in maybeMediaItem)
{
_logger.LogInformation("Moving item at {Path}", await GetPath(mediaItem));
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { mediaItem });
}
}
}
return Unit.Default;
}
private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
MoveLocalLibraryPath request) =>
(await LibraryPathMustExist(dbContext, request), await LocalLibraryMustExist(dbContext, request))
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
TvContext dbContext,
MoveLocalLibraryPath request) =>
dbContext.LibraryPaths
.Include(lp => lp.Library)
.SelectOneAsync(c => c.Id, c => c.Id == request.LibraryPathId)
.Map(o => o.ToValidation<BaseError>("LibraryPath does not exist."));
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
TvContext dbContext,
MoveLocalLibraryPath request) =>
dbContext.LocalLibraries
.Include(ll => ll.Paths)
.SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId)
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist"));
private async Task<string> GetPath(MediaItem mediaItem) =>
mediaItem switch
{
Movie => await _dbConnection.QuerySingleAsync<string>(
@"SELECT Path FROM MediaFile
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
WHERE MV.MovieId = @Id", new { mediaItem.Id }),
Episode => await _dbConnection.QuerySingleAsync<string>(
@"SELECT Path FROM MediaFile
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
WHERE MV.EpisodeId = @Id", new { mediaItem.Id }),
MusicVideo => await _dbConnection.QuerySingleAsync<string>(
@"SELECT Path FROM MediaFile
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
WHERE MV.MusicVideoId = @Id", new { mediaItem.Id }),
_ => null
};
private record Parameters(LibraryPath LibraryPath, LocalLibrary Library);
}
}
@@ -0,0 +1,12 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Libraries.Commands
{
public record UpdateLocalLibraryPath(int Id, string Path);
public record UpdateLocalLibrary(int Id, string Name, List<UpdateLocalLibraryPath> Paths) : ILocalLibraryRequest,
IRequest<Either<BaseError, LocalLibraryViewModel>>;
}
@@ -0,0 +1,110 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Commands
{
public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
IRequestHandler<UpdateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IEntityLocker _entityLocker;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IEntityLocker entityLocker,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_entityLocker = entityLocker;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
UpdateLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
}
private async Task<LocalLibraryViewModel> UpdateLocalLibrary(TvContext dbContext, Parameters parameters)
{
(LocalLibrary existing, LocalLibrary incoming) = parameters;
existing.Name = incoming.Name;
// toAdd
var toAdd = incoming.Paths
.Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path)))
.ToList();
var toRemove = existing.Paths
.Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path)))
.ToList();
existing.Paths.RemoveAll(toRemove.Contains);
existing.Paths.AddRange(toAdd);
await dbContext.SaveChangesAsync();
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id))
{
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
}
return ProjectToViewModel(existing);
}
private static Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
UpdateLocalLibrary request) =>
LocalLibraryMustExist(dbContext, request)
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
.BindT(
parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
.MapT(_ => parameters));
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
TvContext dbContext,
UpdateLocalLibrary request) =>
dbContext.LocalLibraries
.Include(ll => ll.Paths)
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.Id)
.MapT(
existing =>
{
var incoming = new LocalLibrary
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Id = p.Id, Path = p.Path }).ToList(),
MediaSourceId = existing.Id
};
return new Parameters(existing, incoming);
})
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist."));
private record Parameters(LocalLibrary Existing, LocalLibrary Incoming);
private static string NormalizePath(string path)
{
return Path.GetFullPath(new Uri(path).LocalPath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.ToUpperInvariant();
}
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record CountMediaItemsByLibrary(int LibraryId) : IRequest<int>;
}
@@ -0,0 +1,25 @@
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public class CountMediaItemsByLibraryHandler : IRequestHandler<CountMediaItemsByLibrary, int>
{
private readonly IDbConnection _dbConnection;
public CountMediaItemsByLibraryHandler(IDbConnection dbConnection)
{
_dbConnection = dbConnection;
}
public Task<int> Handle(CountMediaItemsByLibrary request, CancellationToken cancellationToken) =>
_dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { request.LibraryId });
}
}
@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record GetAllLocalLibraries : IRequest<List<LocalLibraryViewModel>>;
}
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Queries
{
public class GetAllLocalLibrariesHandler : IRequestHandler<GetAllLocalLibraries, List<LocalLibraryViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public GetAllLocalLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository;
public Task<List<LocalLibraryViewModel>> Handle(
GetAllLocalLibraries request,
CancellationToken cancellationToken) =>
_libraryRepository.GetAll()
.Map(
list => list
.OfType<LocalLibrary>()
.OrderBy(l => l.MediaKind)
.Map(ProjectToViewModel)
.ToList());
}
}
+6 -6
View File
@@ -123,12 +123,12 @@ namespace ErsatzTV.Application.MediaCards
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
{
artwork = JellyfinUrl.ForArtwork(maybeJellyfin, artwork)
artwork = JellyfinUrl.RelativeProxyForArtwork(artwork)
.SetQueryParam("fillHeight", 440);
}
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
{
artwork = EmbyUrl.ForArtwork(maybeEmby, artwork)
artwork = EmbyUrl.RelativeProxyForArtwork(artwork)
.SetQueryParam("maxHeight", 440);
}
@@ -171,12 +171,12 @@ namespace ErsatzTV.Application.MediaCards
if (maybeJellyfin.IsSome && poster.StartsWith("jellyfin://"))
{
poster = JellyfinUrl.ForArtwork(maybeJellyfin, poster)
poster = JellyfinUrl.RelativeProxyForArtwork(poster)
.SetQueryParam("fillHeight", 440);
}
else if (maybeEmby.IsSome && poster.StartsWith("emby://"))
{
poster = EmbyUrl.ForArtwork(maybeEmby, poster)
poster = EmbyUrl.RelativeProxyForArtwork(poster)
.SetQueryParam("maxHeight", 440);
}
@@ -193,12 +193,12 @@ namespace ErsatzTV.Application.MediaCards
if (maybeJellyfin.IsSome && thumb.StartsWith("jellyfin://"))
{
thumb = JellyfinUrl.ForArtwork(maybeJellyfin, thumb)
thumb = JellyfinUrl.RelativeProxyForArtwork(thumb)
.SetQueryParam("fillHeight", 220);
}
else if (maybeEmby.IsSome && thumb.StartsWith("emby://"))
{
thumb = EmbyUrl.ForArtwork(maybeEmby, thumb)
thumb = EmbyUrl.RelativeProxyForArtwork(thumb)
.SetQueryParam("maxHeight", 220);
}
@@ -73,7 +73,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
AddArtistToCollection request) =>
dbContext.Artists
.SelectOneAsync(a => a.Id, a => a.Id == request.ArtistId)
.Map(o => o.ToValidation<BaseError>("Music video does not exist"));
.Map(o => o.ToValidation<BaseError>("Artist does not exist"));
private record Parameters(Collection Collection, Artist Artist);
}
@@ -0,0 +1,13 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollection
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;
}
@@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class CreateMultiCollectionHandler :
IRequestHandler<CreateMultiCollection, Either<BaseError, MultiCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, MultiCollectionViewModel>> Handle(
CreateMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<MultiCollectionViewModel> PersistCollection(
TvContext dbContext,
MultiCollection multiCollection)
{
await dbContext.MultiCollections.AddAsync(multiCollection);
await dbContext.SaveChangesAsync();
await dbContext.Entry(multiCollection)
.Collection(c => c.MultiCollectionItems)
.Query()
.Include(i => i.Collection)
.LoadAsync();
return ProjectToViewModel(multiCollection);
}
private static Task<Validation<BaseError, MultiCollection>> Validate(
TvContext dbContext,
CreateMultiCollection request) =>
ValidateName(dbContext, request).MapT(
name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
}).ToList()
});
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
CreateMultiCollection createMultiCollection)
{
List<string> allNames = await dbContext.MultiCollections
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createMultiCollection.NotEmpty(c => c.Name)
.Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createMultiCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique");
return (result1, result2).Apply((_, _) => createMultiCollection.Name);
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateSmartCollection
(string Query, string Name) : IRequest<Either<BaseError, SmartCollectionViewModel>>;
}
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class CreateSmartCollectionHandler :
IRequestHandler<CreateSmartCollection, Either<BaseError, SmartCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, SmartCollectionViewModel>> Handle(
CreateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<SmartCollectionViewModel> PersistCollection(
TvContext dbContext,
SmartCollection smartCollection)
{
await dbContext.SmartCollections.AddAsync(smartCollection);
await dbContext.SaveChangesAsync();
return ProjectToViewModel(smartCollection);
}
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
CreateSmartCollection request) =>
ValidateName(dbContext, request).MapT(
name => new SmartCollection
{
Name = name,
Query = request.Query
});
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
CreateSmartCollection createSmartCollection)
{
List<string> allNames = await dbContext.SmartCollections
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createSmartCollection.NotEmpty(c => c.Name)
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createSmartCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("SmartCollection name must be unique");
return (result1, result2).Apply((_, _) => createSmartCollection.Name);
}
}
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record DeleteMultiCollection(int MultiCollectionId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,42 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class DeleteMultiCollectionHandler : MediatR.IRequestHandler<DeleteMultiCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Unit>> Handle(
DeleteMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, MultiCollection> validation = await MultiCollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, MultiCollection multiCollection)
{
dbContext.MultiCollections.Remove(multiCollection);
return dbContext.SaveChangesAsync().ToUnit();
}
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(
TvContext dbContext,
DeleteMultiCollection request) =>
dbContext.MultiCollections
.SelectOneAsync(c => c.Id, c => c.Id == request.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>($"MultiCollection {request.MultiCollectionId} does not exist."));
}
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record DeleteSmartCollection(int SmartCollectionId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,42 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class DeleteSmartCollectionHandler : MediatR.IRequestHandler<DeleteSmartCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Unit>> Handle(
DeleteSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
{
dbContext.SmartCollections.Remove(smartCollection);
return dbContext.SaveChangesAsync().ToUnit();
}
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
DeleteSmartCollection request) =>
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId)
.Map(o => o.ToValidation<BaseError>($"SmartCollection {request.SmartCollectionId} does not exist."));
}
}
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record UpdateMultiCollection
(
int MultiCollectionId,
string Name,
List<UpdateMultiCollectionItem> Items) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,127 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateMultiCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdateMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, MultiCollection c, UpdateMultiCollection request)
{
c.Name = request.Name;
// save name first so playouts don't get rebuilt for a name change
await dbContext.SaveChangesAsync();
var toAdd = request.Items
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId))
.Map(
i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
})
.ToList();
var toRemove = c.MultiCollectionItems
.Filter(i => request.Items.All(i2 => i2.CollectionId != i.CollectionId))
.ToList();
// remove items that are no longer present
c.MultiCollectionItems.RemoveAll(toRemove.Contains);
// update existing items
foreach (MultiCollectionItem item in c.MultiCollectionItems)
{
foreach (UpdateMultiCollectionItem incoming in request.Items.Filter(
i => i.CollectionId == item.CollectionId))
{
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
item.PlaybackOrder = incoming.PlaybackOrder;
}
}
// add new items
c.MultiCollectionItems.AddRange(toAdd);
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection(
request.MultiCollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private static async Task<Validation<BaseError, MultiCollection>> Validate(
TvContext dbContext,
UpdateMultiCollection request) =>
(await MultiCollectionMustExist(dbContext, request), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(
TvContext dbContext,
UpdateMultiCollection updateCollection) =>
dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName(TvContext dbContext, UpdateMultiCollection updateMultiCollection)
{
List<string> allNames = await dbContext.MultiCollections
.Filter(mc => mc.Id != updateMultiCollection.MultiCollectionId)
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = updateMultiCollection.NotEmpty(c => c.Name)
.Bind(_ => updateMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(updateMultiCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique");
return (result1, result2).Apply((_, _) => updateMultiCollection.Name);
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateSmartCollection(int Id, string Query) : IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateSmartCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, SmartCollection c, UpdateSmartCollection request)
{
c.Query = request.Query;
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
UpdateSmartCollection request) => SmartCollectionMustExist(dbContext, request);
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
UpdateSmartCollection updateCollection) =>
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id)
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
}
}
@@ -1,10 +1,28 @@
using ErsatzTV.Core.Domain;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections
{
internal static class Mapper
{
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
new(collection.Id, collection.Name);
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new(
multiCollection.Id,
multiCollection.Name,
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList());
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
new(
multiCollectionItem.MultiCollectionId,
ProjectToViewModel(multiCollectionItem.Collection),
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder);
}
}
@@ -2,7 +2,7 @@
namespace ErsatzTV.Application.MediaCollections
{
public record MediaCollectionViewModel(int Id, string Name) : MediaCardViewModel(
public record MediaCollectionViewModel(int Id, string Name, bool UseCustomPlaybackOrder) : MediaCardViewModel(
Id,
Name,
string.Empty,
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionItemViewModel(
int MultiCollectionId,
MediaCollectionViewModel Collection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
}
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionViewModel(int Id, string Name, List<MultiCollectionItemViewModel> Items);
}
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedMultiCollectionsViewModel(int TotalCount, List<MultiCollectionViewModel> Page);
}
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedSmartCollectionsViewModel(int TotalCount, List<SmartCollectionViewModel> Page);
}
@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetAllMultiCollections : IRequest<List<MultiCollectionViewModel>>;
}
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetAllMultiCollectionsHandler : IRequestHandler<GetAllMultiCollections, List<MultiCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllMultiCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<MultiCollectionViewModel>> Handle(
GetAllMultiCollections request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MultiCollections
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}
}
@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetAllSmartCollections : IRequest<List<SmartCollectionViewModel>>;
}
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetAllSmartCollectionsHandler : IRequestHandler<GetAllSmartCollections, List<SmartCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<SmartCollectionViewModel>> Handle(
GetAllSmartCollections request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.SmartCollections
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}
}
@@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetMultiCollectionById(int Id) : IRequest<Option<MultiCollectionViewModel>>;
}
@@ -0,0 +1,31 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetMultiCollectionByIdHandler : IRequestHandler<GetMultiCollectionById, Option<MultiCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetMultiCollectionByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<MultiCollectionViewModel>> Handle(
GetMultiCollectionById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.ThenInclude(mc => mc.Collection)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.MapT(ProjectToViewModel);
}
}
}
@@ -33,6 +33,7 @@ namespace ErsatzTV.Application.MediaCollections.Queries
List<MediaCollectionViewModel> page = await dbContext.Collections.FromSqlRaw(
@"SELECT * FROM Collection
ORDER BY Name
COLLATE NOCASE
LIMIT {0} OFFSET {1}",
request.PageSize,
request.PageNum * request.PageSize)
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedMultiCollections(int PageNum, int PageSize) : IRequest<PagedMultiCollectionsViewModel>;
}
@@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetPagedMultiCollectionsHandler : IRequestHandler<GetPagedMultiCollections, PagedMultiCollectionsViewModel>
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<PagedMultiCollectionsViewModel> Handle(
GetPagedMultiCollections request,
CancellationToken cancellationToken)
{
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM MultiCollection");
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<MultiCollectionViewModel> page = await dbContext.MultiCollections.FromSqlRaw(
@"SELECT * FROM MultiCollection
ORDER BY Name
COLLATE NOCASE
LIMIT {0} OFFSET {1}",
request.PageSize,
request.PageNum * request.PageSize)
.Include(mc => mc.MultiCollectionItems)
.ThenInclude(i => i.Collection)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedMultiCollectionsViewModel(count, page);
}
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedSmartCollections(int PageNum, int PageSize) : IRequest<PagedSmartCollectionsViewModel>;
}
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetPagedSmartCollectionsHandler : IRequestHandler<GetPagedSmartCollections, PagedSmartCollectionsViewModel>
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<PagedSmartCollectionsViewModel> Handle(
GetPagedSmartCollections request,
CancellationToken cancellationToken)
{
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM SmartCollection");
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<SmartCollectionViewModel> page = await dbContext.SmartCollections.FromSqlRaw(
@"SELECT * FROM SmartCollection
ORDER BY Name
COLLATE NOCASE
LIMIT {0} OFFSET {1}",
request.PageSize,
request.PageNum * request.PageSize)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedSmartCollectionsViewModel(count, page);
}
}
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaCollections
{
public record SmartCollectionViewModel(int Id, string Name, string Query);
}
+2 -2
View File
@@ -66,7 +66,7 @@ namespace ErsatzTV.Application.Movies
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
{
Url url = JellyfinUrl.ForArtwork(maybeJellyfin, artwork);
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
@@ -76,7 +76,7 @@ namespace ErsatzTV.Application.Movies
}
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
{
Url url = EmbyUrl.ForArtwork(maybeEmby, artwork);
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("maxHeight", 440);
@@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -74,13 +75,15 @@ namespace ErsatzTV.Application.Plex.Commands
await _plexMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library);
parameters.Library,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
await _plexTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library);
parameters.Library,
parameters.FFprobePath);
break;
}
@@ -100,13 +103,14 @@ namespace ErsatzTV.Application.Plex.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate(ISynchronizePlexLibraryById request) =>
(await ValidateConnection(request), await PlexLibraryMustExist(request),
await ValidateLibraryRefreshInterval())
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply(
(connectionParameters, plexLibrary, libraryRefreshInterval) => new RequestParameters(
(connectionParameters, plexLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters,
plexLibrary,
request.ForceScan,
libraryRefreshInterval
libraryRefreshInterval,
ffprobePath
));
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
@@ -149,12 +153,20 @@ namespace ErsatzTV.Application.Plex.Commands
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(
ConnectionParameters ConnectionParameters,
PlexLibrary Library,
bool ForceScan,
int LibraryRefreshInterval);
int LibraryRefreshInterval,
string FFprobePath);
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
{
@@ -13,7 +13,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail,
@@ -7,7 +7,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
{
public record CreateProgramSchedule(
string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
}
@@ -45,13 +45,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ValidateName(dbContext, request),
name =>
{
bool keepMultiPartEpisodesTogether =
request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
request.KeepMultiPartEpisodesTogether;
bool keepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
return new ProgramSchedule
{
Name = name,
MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder,
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows
};
@@ -8,8 +8,11 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
TimeSpan? StartTime { get; }
ProgramScheduleItemCollectionType CollectionType { get; }
int? CollectionId { get; }
int? MultiCollectionId { get; }
int? SmartCollectionId { get; }
int? MediaItemId { get; }
PlayoutMode PlayoutMode { get; }
PlaybackOrder PlaybackOrder { get; }
int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; }
bool? OfflineTail { get; }
@@ -24,15 +24,28 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
IProgramScheduleItemRequest item,
ProgramSchedule programSchedule)
{
if (item.MultiCollectionId.HasValue)
{
switch (item.PlaybackOrder)
{
case PlaybackOrder.Chronological:
case PlaybackOrder.Random:
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
case PlaybackOrder.Shuffle:
case PlaybackOrder.ShuffleInOrder:
break;
}
}
switch (item.PlayoutMode)
{
case PlayoutMode.Flood:
case PlayoutMode.One:
break;
case PlayoutMode.Multiple:
if (item.MultipleCount.GetValueOrDefault() <= 0)
if (item.MultipleCount.GetValueOrDefault() < 0)
{
return BaseError.New("[MultipleCount] must be greater than 0 for playout mode 'multiple'");
return BaseError.New("[MultipleCount] must be greater than or equal to 0 for playout mode 'multiple'");
}
break;
@@ -88,6 +101,20 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[MediaItem] is required for collection type 'Artist'");
}
break;
case ProgramScheduleItemCollectionType.MultiCollection:
if (item.MultiCollectionId is null)
{
return BaseError.New("[MultiCollection] is required for collection type 'MultiCollection'");
}
break;
case ProgramScheduleItemCollectionType.SmartCollection:
if (item.SmartCollectionId is null)
{
return BaseError.New("[SmartCollection] is required for collection type 'SmartCollection'");
}
break;
default:
return BaseError.New("[CollectionType] is invalid");
@@ -109,7 +136,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
},
PlayoutMode.One => new ProgramScheduleItemOne
@@ -119,7 +149,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
},
PlayoutMode.Multiple => new ProgramScheduleItemMultiple
@@ -129,7 +162,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle
},
@@ -140,7 +176,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
OfflineTail = item.OfflineTail.GetValueOrDefault(),
CustomTitle = item.CustomTitle
@@ -14,7 +14,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail,
@@ -11,6 +11,7 @@ using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.ProgramSchedules.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.ProgramSchedules.Commands
{
@@ -61,7 +62,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ReplaceProgramScheduleItems request) =>
ProgramScheduleMustExist(dbContext, request.ProgramScheduleId)
.BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule))
.BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule));
.BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule))
.BindT(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule));
private static Validation<BaseError, ProgramSchedule> PlayoutModesMustBeValid(
ReplaceProgramScheduleItems request,
@@ -74,5 +76,43 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramSchedule programSchedule) =>
request.Items.Map(item => CollectionTypeMustBeValid(item, programSchedule)).Sequence()
.Map(_ => programSchedule);
private static Validation<BaseError, ProgramSchedule> PlaybackOrdersMustBeValid(
ReplaceProgramScheduleItems request,
ProgramSchedule programSchedule)
{
var keyOrders = new Dictionary<CollectionKey, System.Collections.Generic.HashSet<PlaybackOrder>>();
foreach (ReplaceProgramScheduleItem item in request.Items)
{
var key = new CollectionKey(
item.CollectionType,
item.CollectionId,
item.MediaItemId,
item.MultiCollectionId,
item.SmartCollectionId);
if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet<PlaybackOrder> playbackOrders))
{
playbackOrders.Add(item.PlaybackOrder);
keyOrders[key] = playbackOrders;
}
else
{
keyOrders.Add(key, new System.Collections.Generic.HashSet<PlaybackOrder> { item.PlaybackOrder });
}
}
return Optional(keyOrders.Values.Count(set => set.Count != 1))
.Filter(count => count == 0)
.Map(_ => programSchedule)
.ToValidation<BaseError>("A collection must not use multiple playback orders");
}
private record CollectionKey(
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId);
}
}
@@ -9,7 +9,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
(
int ProgramScheduleId,
string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
}
@@ -44,15 +44,11 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
{
// we need to rebuild playouts if the playback order or keep multi-episodes has been modified
bool needToRebuildPlayout =
programSchedule.MediaCollectionPlaybackOrder != request.MediaCollectionPlaybackOrder ||
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows;
programSchedule.Name = request.Name;
programSchedule.MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder;
programSchedule.KeepMultiPartEpisodesTogether =
request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
request.KeepMultiPartEpisodesTogether;
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
request.TreatCollectionsAsShows;
@@ -9,7 +9,6 @@ namespace ErsatzTV.Application.ProgramSchedules
new(
programSchedule.Id,
programSchedule.Name,
programSchedule.MediaCollectionPlaybackOrder,
programSchedule.KeepMultiPartEpisodesTogether,
programSchedule.TreatCollectionsAsShows);
@@ -26,6 +25,12 @@ namespace ErsatzTV.Application.ProgramSchedules
duration.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.Collection)
: null,
duration.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.MultiCollection)
: null,
duration.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.SmartCollection)
: null,
duration.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -33,6 +38,7 @@ namespace ErsatzTV.Application.ProgramSchedules
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
duration.PlaybackOrder,
duration.PlayoutDuration,
duration.OfflineTail,
duration.CustomTitle),
@@ -46,6 +52,12 @@ namespace ErsatzTV.Application.ProgramSchedules
flood.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.Collection)
: null,
flood.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.MultiCollection)
: null,
flood.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.SmartCollection)
: null,
flood.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -53,6 +65,7 @@ namespace ErsatzTV.Application.ProgramSchedules
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
flood.PlaybackOrder,
flood.CustomTitle),
ProgramScheduleItemMultiple multiple =>
new ProgramScheduleItemMultipleViewModel(
@@ -64,6 +77,12 @@ namespace ErsatzTV.Application.ProgramSchedules
multiple.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.Collection)
: null,
multiple.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.MultiCollection)
: null,
multiple.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.SmartCollection)
: null,
multiple.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -71,6 +90,7 @@ namespace ErsatzTV.Application.ProgramSchedules
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
multiple.PlaybackOrder,
multiple.Count,
multiple.CustomTitle),
ProgramScheduleItemOne one =>
@@ -83,6 +103,12 @@ namespace ErsatzTV.Application.ProgramSchedules
one.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(one.Collection)
: null,
one.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(one.MultiCollection)
: null,
one.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(one.SmartCollection)
: null,
one.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -90,6 +116,7 @@ namespace ErsatzTV.Application.ProgramSchedules
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
one.PlaybackOrder,
one.CustomTitle),
_ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
@@ -14,7 +14,10 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
TimeSpan playoutDuration,
bool offlineTail,
string customTitle) : base(
@@ -25,7 +28,10 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Duration,
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)
{
PlayoutDuration = playoutDuration;
@@ -14,7 +14,10 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
id,
index,
@@ -23,7 +26,10 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Flood,
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)
{
}
@@ -14,7 +14,10 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
int count,
string customTitle) : base(
id,
@@ -24,7 +27,10 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Multiple,
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle) =>
Count = count;
@@ -14,7 +14,10 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
id,
index,
@@ -23,7 +26,10 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.One,
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)
{
}
@@ -13,7 +13,10 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection,
MultiCollectionViewModel MultiCollection,
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
string CustomTitle)
{
public string Name => CollectionType switch
@@ -25,6 +28,10 @@ namespace ErsatzTV.Application.ProgramSchedules
MediaItem?.Name, // $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})",
ProgramScheduleItemCollectionType.Artist =>
MediaItem?.Name,
ProgramScheduleItemCollectionType.MultiCollection =>
MultiCollection?.Name,
ProgramScheduleItemCollectionType.SmartCollection =>
SmartCollection?.Name,
_ => string.Empty
};
}
@@ -1,11 +1,8 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules
namespace ErsatzTV.Application.ProgramSchedules
{
public record ProgramScheduleViewModel(
int Id,
string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows);
}
@@ -24,7 +24,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
ps => new ProgramScheduleViewModel(
ps.Id,
ps.Name,
ps.MediaCollectionPlaybackOrder,
ps.KeepMultiPartEpisodesTogether,
ps.TreatCollectionsAsShows))
.ToListAsync(cancellationToken);
@@ -27,6 +27,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
return await dbContext.ProgramScheduleItems
.Filter(psi => psi.ProgramScheduleId == request.Id)
.Include(i => i.Collection)
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
+2 -2
View File
@@ -80,7 +80,7 @@ namespace ErsatzTV.Application.Television
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
{
Url url = JellyfinUrl.ForArtwork(maybeJellyfin, artwork);
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind == ArtworkKind.Poster)
{
url.SetQueryParam("fillHeight", 440);
@@ -90,7 +90,7 @@ namespace ErsatzTV.Application.Television
}
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
{
Url url = EmbyUrl.ForArtwork(maybeEmby, artwork);
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
if (artworkKind == ArtworkKind.Poster)
{
url.SetQueryParam("maxHeight", 440);
@@ -10,17 +10,17 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="FluentAssertions" Version="6.1.0" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
namespace ErsatzTV.Core.Tests.Fakes
@@ -18,7 +19,23 @@ namespace ErsatzTV.Core.Tests.Fakes
throw new NotSupportedException();
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => throw new NotSupportedException();
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
throw new NotSupportedException();
public Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId) =>
throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId) =>
throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingSmartCollection(int smartCollectionId) =>
throw new NotSupportedException();
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
}
}
@@ -91,7 +91,32 @@ namespace ErsatzTV.Core.Tests.Metadata
});
metadata.Count.Should().Be(2);
metadata.Map(m => m.EpisodeNumber).Should().BeEquivalentTo(episode1, episode2);
metadata.Map(m => m.EpisodeNumber).Should().BeEquivalentTo(new[] { episode1, episode2 });
}
[Test]
[TestCase("Something (2021).mkv", "Something")]
[TestCase("Something Else (2021).mkv", "Something Else")]
public void GetFallbackMetadata_Should_Set_Proper_Movie_Title(string path, string expectedTitle)
{
MovieMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadata(
new Movie
{
LibraryPath = new LibraryPath(),
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
}
}
});
metadata.Should().NotBeNull();
metadata.Title.Should().Be(expectedTitle);
}
}
}
@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata
{
[TestFixture]
public class LocalStatisticsProviderTests
{
[Test]
// this needs to be a culture where '.' is a group separator
[SetCulture("it-IT")]
public void Test()
{
var provider = new LocalStatisticsProvider(
new Mock<IMetadataRepository>().Object,
new Mock<ILocalFileSystem>().Object,
new Mock<ILogger<LocalStatisticsProvider>>().Object);
var input = new LocalStatisticsProvider.FFprobe(
new LocalStatisticsProvider.FFprobeFormat("123.45"),
new List<LocalStatisticsProvider.FFprobeStream>());
MediaVersion result = provider.ProjectToMediaVersion("test", input);
result.Duration.Should().Be(TimeSpan.FromSeconds(123.45));
}
}
}
@@ -405,6 +405,48 @@ namespace ErsatzTV.Core.Tests.Metadata
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
}
[Test]
public async Task Should_Ignore_Dot_Underscore_Files(
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))]
string videoExtension)
{
string moviePath = Path.Combine(
FakeRoot,
Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}"));
MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
new FakeFileEntry(
Path.Combine(
Path.GetDirectoryName(moviePath) ?? string.Empty,
$"._Movie (2020){videoExtension}"))
);
var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
0,
1);
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
}
[Test]
public async Task Should_Ignore_Extra_Folders(
@@ -329,14 +329,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
StartTime = null
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemOne
{
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(3)
StartTime = TimeSpan.FromHours(3),
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -344,15 +346,20 @@ namespace ErsatzTV.Core.Tests.Scheduling
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
};
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
@@ -409,7 +416,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
StartTime = null
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemMultiple
{
@@ -417,7 +425,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(3),
Count = 2
Count = 2,
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -425,15 +434,20 @@ namespace ErsatzTV.Core.Tests.Scheduling
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
};
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(7);
@@ -458,6 +472,196 @@ namespace ErsatzTV.Core.Tests.Scheduling
result.Items[5].MediaItemId.Should().Be(2);
}
[Test]
public async Task FloodContent_Should_FloodWithFixedStartTime()
{
var floodCollection = new Collection
{
Id = 1,
Name = "Flood Items",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
}
};
var fixedCollection = new Collection
{
Id = 2,
Name = "Fixed Items",
MediaItems = new List<MediaItem>
{
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)),
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(floodCollection.Id, floodCollection.MediaItems.ToList()),
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemFlood
{
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
StartTime = TimeSpan.FromHours(7),
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemOne
{
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(12),
PlaybackOrder = PlaybackOrder.Chronological
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
};
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(24);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(6);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(8));
result.Items[1].MediaItemId.Should().Be(2);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
result.Items[2].MediaItemId.Should().Be(1);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
result.Items[3].MediaItemId.Should().Be(2);
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
result.Items[4].MediaItemId.Should().Be(1);
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
result.Items[5].MediaItemId.Should().Be(3);
}
[Test]
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
{
var floodCollection = new Collection
{
Id = 1,
Name = "Flood Items",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
}
};
var fixedCollection = new Collection
{
Id = 2,
Name = "Fixed Items",
MediaItems = new List<MediaItem>
{
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)),
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(floodCollection.Id, floodCollection.MediaItems.ToList()),
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemFlood
{
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
StartTime = TimeSpan.FromHours(7),
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemOne
{
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(12),
PlaybackOrder = PlaybackOrder.Chronological
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor
{
NextStart = HoursAfterMidnight(9).UtcDateTime,
NextScheduleItem = items[0],
NextScheduleItemId = 1,
InFlood = true
}
};
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(32);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(5);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
result.Items[1].MediaItemId.Should().Be(2);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
result.Items[2].MediaItemId.Should().Be(1);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
result.Items[3].MediaItemId.Should().Be(3);
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
result.Items[4].MediaItemId.Should().Be(2);
result.Anchor.InFlood.Should().BeTrue();
}
[Test]
public async Task FloodContent_Should_FloodAroundFixedContent_DurationWithoutOfflineTail()
{
@@ -495,7 +699,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
StartTime = null
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemDuration
{
@@ -504,7 +709,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(2),
PlayoutDuration = TimeSpan.FromHours(2),
OfflineTail = false // immediately continue
OfflineTail = false, // immediately continue
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -512,15 +718,20 @@ namespace ErsatzTV.Core.Tests.Scheduling
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
};
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
@@ -586,7 +797,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Collection = multipleCollection,
CollectionId = multipleCollection.Id,
StartTime = null,
Count = 2
Count = 2,
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemDuration
{
@@ -595,7 +807,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = dynamicCollection.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(2),
OfflineTail = false // immediately continue
OfflineTail = false, // immediately continue
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -603,15 +816,20 @@ namespace ErsatzTV.Core.Tests.Scheduling
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
};
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
@@ -674,7 +892,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 3
Count = 3,
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemMultiple
{
@@ -683,7 +902,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
Count = 3
Count = 3,
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -691,8 +911,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor
@@ -704,9 +923,15 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
};
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
@@ -729,6 +954,103 @@ namespace ErsatzTV.Core.Tests.Scheduling
result.Anchor.MultipleRemaining.Should().Be(1);
}
[Test]
public async Task Auto_Zero_MultipleCount()
{
var collectionOne = new Collection
{
Id = 1,
Name = "Multiple Items 1",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(3, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var collectionTwo = new Collection
{
Id = 2,
Name = "Multiple Items 2",
MediaItems = new List<MediaItem>
{
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(5, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(collectionOne.Id, collectionOne.MediaItems.ToList()),
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemMultiple
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 0,
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemMultiple
{
Id = 2,
Index = 2,
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
Count = 0,
PlaybackOrder = PlaybackOrder.Chronological
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
};
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(5);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(0));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
result.Items[1].MediaItemId.Should().Be(2);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
result.Items[2].MediaItemId.Should().Be(3);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[3].MediaItemId.Should().Be(4);
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4));
result.Items[4].MediaItemId.Should().Be(5);
result.Anchor.NextScheduleItem.Should().Be(items[0]);
result.Anchor.MultipleRemaining.Should().BeNull();
}
[Test]
public async Task Alternating_Duration_Should_Maintain_Duration()
{
@@ -767,7 +1089,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = collectionOne.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false
OfflineTail = false,
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemDuration
{
@@ -777,7 +1100,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = collectionTwo.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false
OfflineTail = false,
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -785,8 +1109,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor
@@ -798,9 +1121,15 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
};
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
@@ -829,13 +1158,14 @@ namespace ErsatzTV.Core.Tests.Scheduling
return now - now.TimeOfDay + TimeSpan.FromHours(hours);
}
private static ProgramScheduleItem Flood(Collection mediaCollection) =>
private static ProgramScheduleItem Flood(Collection mediaCollection, PlaybackOrder playbackOrder) =>
new ProgramScheduleItemFlood
{
Index = 1,
Collection = mediaCollection,
CollectionId = mediaCollection.Id,
StartTime = null
StartTime = null,
PlaybackOrder = playbackOrder
};
private static Movie TestMovie(int id, TimeSpan duration, DateTime aired) =>
@@ -857,17 +1187,23 @@ namespace ErsatzTV.Core.Tests.Scheduling
MediaItems = mediaItems
};
var configRepo = new Mock<IConfigElementRepository>();
var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems)));
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(collectionRepo, televisionRepo, artistRepo.Object, _logger);
var builder = new PlayoutBuilder(
configRepo.Object,
collectionRepo,
televisionRepo,
artistRepo.Object,
_logger);
var items = new List<ProgramScheduleItem> { Flood(mediaCollection) };
var items = new List<ProgramScheduleItem> { Flood(mediaCollection, playbackOrder) };
var playout = new Playout
{
Id = 1,
ProgramSchedule = new ProgramSchedule { Items = items, MediaCollectionPlaybackOrder = playbackOrder },
ProgramSchedule = new ProgramSchedule { Items = items },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
};
@@ -23,7 +23,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
// normally returns 10 5 7 4 3 6 2 8 9 1 1 (note duplicate 1 at end)
var state = new CollectionEnumeratorState { Seed = 8 };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
var list = new List<int>();
for (var i = 1; i <= 1000; i++)
@@ -50,7 +51,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
var list = new List<int>();
for (var i = 1; i <= 10; i++)
@@ -70,7 +72,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
var list = new List<int>();
for (var i = 1; i <= 10; i++)
@@ -81,7 +84,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
list.Should().NotEqual(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 });
list.Should().BeEquivalentTo(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
list.Should().BeEquivalentTo(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 });
}
[Test]
@@ -90,7 +93,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
for (var i = 0; i < 10; i++)
{
@@ -105,7 +109,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 5, Seed = MagicSeed };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
for (var i = 6; i <= 10; i++)
{
@@ -123,7 +128,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 10, Seed = MagicSeed };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
shuffledContent.State.Index.Should().Be(0);
shuffledContent.State.Seed.Should().NotBe(MagicSeed);
@@ -9,5 +9,7 @@ namespace ErsatzTV.Core.Domain
public bool UseCustomPlaybackOrder { get; set; }
public List<MediaItem> MediaItems { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
public List<MultiCollection> MultiCollections { get; set; }
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
}
}
@@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class MultiCollection
{
public int Id { get; set; }
public string Name { get; set; }
public List<Collection> Collections { get; set; }
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
}
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain
{
public class MultiCollectionItem
{
public int MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int CollectionId { get; set; }
public Collection Collection { get; set; }
public bool ScheduleAsGroup { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public class SmartCollection
{
public int Id { get; set; }
public string Name { get; set; }
public string Query { get; set; }
}
}
+3
View File
@@ -17,11 +17,14 @@
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
public static ConfigElementKey MultiCollectionsPageSize => new("pages.multi_collections.page_size");
public static ConfigElementKey SmartCollectionsPageSize => new("pages.smart_collections.page_size");
public static ConfigElementKey SchedulesPageSize => new("pages.schedules.page_size");
public static ConfigElementKey SchedulesDetailPageSize => new("pages.schedules.detail_page_size");
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");
public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size");
public static ConfigElementKey LogsPageSize => new("pages.logs.page_size");
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
}
}
@@ -12,6 +12,8 @@
public string Title { get; set; }
public bool Default { get; set; }
public bool Forced { get; set; }
public string PixelFormat { get; set; }
public int BitsPerRawSample { get; set; }
public int MediaVersionId { get; set; }
public MediaVersion MediaVersion { get; set; }
}
+2 -1
View File
@@ -4,6 +4,7 @@
{
Chronological = 1,
Random = 2,
Shuffle = 3
Shuffle = 3,
ShuffleInOrder = 4
}
}
+1
View File
@@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Domain
public DateTime NextStart { get; set; }
public int? MultipleRemaining { get; set; }
public DateTime? DurationFinish { get; set; }
public bool InFlood { get; set; }
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
@@ -10,6 +10,10 @@
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? CollectionId { get; set; }
public Collection Collection { get; set; }
public int? MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int? SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public CollectionEnumeratorState EnumeratorState { get; set; }
-1
View File
@@ -6,7 +6,6 @@ namespace ErsatzTV.Core.Domain
{
public int Id { get; set; }
public string Name { get; set; }
public PlaybackOrder MediaCollectionPlaybackOrder { get; set; }
public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; }
public List<ProgramScheduleItem> Items { get; set; }
@@ -16,5 +16,10 @@ namespace ErsatzTV.Core.Domain
public Collection Collection { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public int? MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int? SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
}
}
@@ -5,6 +5,8 @@
Collection = 0,
TelevisionShow = 1,
TelevisionSeason = 2,
Artist = 3
Artist = 3,
MultiCollection = 4,
SmartCollection = 5
}
}

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