Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97725ac92d | ||
|
|
6b90da8982 | ||
|
|
1184dc565c | ||
|
|
d82ccf8fb5 | ||
|
|
4d83cc705f | ||
|
|
f80addacba | ||
|
|
18c2a816dc | ||
|
|
4f085c1950 | ||
|
|
dad0662fa6 | ||
|
|
dfdfa6f349 | ||
|
|
5fe3e97b31 | ||
|
|
da1cfab5f4 | ||
|
|
6d152e4b4a | ||
|
|
2ca722523b | ||
|
|
d9a3496bf5 | ||
|
|
c8f5b51d93 | ||
|
|
956734ce39 | ||
|
|
e44a391f00 | ||
|
|
b43b66dd35 | ||
|
|
140a663da4 | ||
|
|
876d79c11c | ||
|
|
57a4480c3f | ||
|
|
8f1b57eb88 | ||
|
|
70472ac84e | ||
|
|
6aab8f53b8 | ||
|
|
b30b458574 | ||
|
|
6fe6382485 | ||
|
|
100ae0adda | ||
|
|
f0d5200843 | ||
|
|
af36218cc2 | ||
|
|
eca62e8bec | ||
|
|
03d4ab8c72 | ||
|
|
57b590cd0f | ||
|
|
f0a5d89f73 | ||
|
|
17a77694a0 | ||
|
|
838c2a1661 | ||
|
|
375a306edc | ||
|
|
759052c725 | ||
|
|
dc112f0c7d | ||
|
|
c8ec87b01f | ||
|
|
fbb7a661fb | ||
|
|
37ceac5651 | ||
|
|
0953e258a5 | ||
|
|
fdbd8a07b6 | ||
|
|
5465c45a51 | ||
|
|
a0bef3568b | ||
|
|
f75bb25a1a | ||
|
|
e4ff825ae8 | ||
|
|
6c4f63ad91 | ||
|
|
c0a14ba81c | ||
|
|
c063720169 | ||
|
|
5d0f40978d | ||
|
|
063f463951 | ||
|
|
e7817b1460 | ||
|
|
7d6faee27b | ||
|
|
7bba422880 | ||
|
|
db6ae27384 | ||
|
|
4b9fc5004f | ||
|
|
f40eaef898 | ||
|
|
91e85cc9c1 | ||
|
|
2c44efb971 | ||
|
|
c2b7be66af | ||
|
|
8b911332a6 | ||
|
|
4130f7316c | ||
|
|
3f6eb5a121 | ||
|
|
1209c54eb9 | ||
|
|
94db4bf679 | ||
|
|
2977590a14 | ||
|
|
b4c168e85e | ||
|
|
55b7a35689 | ||
|
|
a24592a8c4 | ||
|
|
9b60ff0863 | ||
|
|
efdf0bb6d4 | ||
|
|
39ca27cb3d | ||
|
|
9e2f7b7815 | ||
|
|
101d46e283 | ||
|
|
521e4eac41 | ||
|
|
894fc284b2 | ||
|
|
a8cf22e43e | ||
|
|
4c9c047530 | ||
|
|
912f79097d | ||
|
|
8aa55fdfce | ||
|
|
8dc1cab222 | ||
|
|
961fe8bbf2 | ||
|
|
75f991d670 | ||
|
|
e3c981004b | ||
|
|
befaa037e2 | ||
|
|
5e0fb31069 | ||
|
|
7d83e66ba6 | ||
|
|
391528cd94 | ||
|
|
b737775f9a | ||
|
|
728c5130b5 | ||
|
|
e4253276e0 | ||
|
|
1fc55bc693 | ||
|
|
4ad22e402f | ||
|
|
ec99d5976d | ||
|
|
59f11f1a1a | ||
|
|
694f25f8b3 | ||
|
|
5947555e86 | ||
|
|
fb63116b36 | ||
|
|
56a58d7a84 | ||
|
|
6f66909957 | ||
|
|
01090f62e6 | ||
|
|
e4e4f68eb4 | ||
|
|
8488fe5d3d | ||
|
|
f06ef5262a | ||
|
|
ae6bcc4933 | ||
|
|
b83fe53ef1 | ||
|
|
d50f2ace07 | ||
|
|
23684f607a | ||
|
|
fa20c5e01e | ||
|
|
53bd745678 | ||
|
|
f3e5a4e7d8 | ||
|
|
0b29bb32b1 | ||
|
|
d9a7615cf6 | ||
|
|
50f2cb7a33 | ||
|
|
b1b2c2a1e0 | ||
|
|
d842cd57f6 | ||
|
|
4f393d7b06 | ||
|
|
46f7289db8 | ||
|
|
80ccbbf299 | ||
|
|
3765894cb7 | ||
|
|
a8b658a5ea | ||
|
|
0e3c32bd83 | ||
|
|
9dd4a85bf9 | ||
|
|
a0a047ba18 | ||
|
|
687a4f4f10 | ||
|
|
b91ab5d898 | ||
|
|
256042947d | ||
|
|
85029cbbcd | ||
|
|
b5d679212d | ||
|
|
36e86587ef | ||
|
|
f41fa669be | ||
|
|
f62e841af4 | ||
|
|
109960c457 | ||
|
|
6858103be5 | ||
|
|
f7f1a0493b | ||
|
|
6c18648fd7 | ||
|
|
ab3afcfad0 | ||
|
|
5734356d29 | ||
|
|
beee038be3 | ||
|
|
a5c8e2b401 | ||
|
|
c8113bdf25 | ||
|
|
9466cf7626 | ||
|
|
43fcf9e63a | ||
|
|
242a7ae382 | ||
|
|
6dc526a817 | ||
|
|
182c02b865 | ||
|
|
7c1f0d6dbd | ||
|
|
2c97c49763 | ||
|
|
f5038c2b66 | ||
|
|
85eb7623da | ||
|
|
a87ec2d75d | ||
|
|
ef6c8b0816 | ||
|
|
dc3578660f | ||
|
|
ce958bb7bb | ||
|
|
33a8b29a27 | ||
|
|
48a504735c | ||
|
|
0b64e97df6 | ||
|
|
5d89f5d0a4 | ||
|
|
50a6ed4cea | ||
|
|
66b8c8aa0e | ||
|
|
398a3c041a | ||
|
|
6ce7265427 | ||
|
|
b4fe38d4ae | ||
|
|
e19b639527 | ||
|
|
a6d5df3ca6 | ||
|
|
202ae33e37 | ||
|
|
c46b3787d8 | ||
|
|
706a2d14a2 | ||
|
|
11f99216a3 | ||
|
|
b9a7ad2f5a | ||
|
|
07e7e5fe66 | ||
|
|
4a19d046e4 | ||
|
|
c1d6ddcc57 | ||
|
|
35eb200aee | ||
|
|
19af303d76 | ||
|
|
da20393a39 | ||
|
|
d78110f6f1 | ||
|
|
c1bedb661c | ||
|
|
d31d6f20cf | ||
|
|
7469559bb3 | ||
|
|
af5dc0968b | ||
|
|
6e7880386b | ||
|
|
8945a87f9b | ||
|
|
e305222141 | ||
|
|
2e2523c380 | ||
|
|
b461631be9 | ||
|
|
19fc068a73 | ||
|
|
e5f15df196 | ||
|
|
6ac2384cbc |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2023.3.3",
|
||||
"version": "2024.1.1",
|
||||
"commands": [
|
||||
"jb"
|
||||
]
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
[*]
|
||||
charset=utf-8
|
||||
end_of_line=lf
|
||||
trim_trailing_whitespace=false
|
||||
trim_trailing_whitespace=true
|
||||
insert_final_newline=false
|
||||
indent_style=space
|
||||
indent_size=4
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
*.cs text diff=csharp
|
||||
*.cshtml text diff=html
|
||||
*.csx text diff=csharp
|
||||
*.sln text eol=crlf
|
||||
*.csproj text eol=crlf
|
||||
@@ -33,10 +33,10 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-11
|
||||
- os: macos-13
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-11
|
||||
- os: macos-13
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
steps:
|
||||
@@ -48,8 +48,6 @@ jobs:
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -87,7 +85,7 @@ jobs:
|
||||
- name: Sign
|
||||
shell: bash
|
||||
run: scripts/macOS/sign.sh
|
||||
|
||||
|
||||
- name: Create DMG
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -102,6 +100,7 @@ jobs:
|
||||
--hide-extension "ErsatzTV.app" \
|
||||
--app-drop-link 600 185 \
|
||||
--skip-jenkins \
|
||||
--no-internet-enable \
|
||||
"ErsatzTV.dmg" \
|
||||
"ErsatzTV.app/"
|
||||
|
||||
@@ -131,7 +130,7 @@ jobs:
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.dmg
|
||||
@@ -147,6 +146,9 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-musl-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm
|
||||
@@ -164,8 +166,6 @@ jobs:
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
id: downloadffmpeg
|
||||
name: Download ffmpeg
|
||||
with:
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/6.1-working-cuvid/ffmpeg-6.1-working-cuvid.7z"
|
||||
url: "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-06-12-14-05/ffmpeg-n7.1.1-22-g0f1fe3d153-win64-gpl-7.1.zip"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Build
|
||||
@@ -190,8 +190,11 @@ jobs:
|
||||
|
||||
# Build everything
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
mkdir "$release_name"
|
||||
mv scanner/* "$release_name/"
|
||||
mv main/* "$release_name/"
|
||||
|
||||
# Build Windows launcher
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
@@ -230,7 +233,7 @@ jobs:
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.zip
|
||||
|
||||
@@ -19,14 +19,14 @@ jobs:
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
tag2="${tag:1}"
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag2/beta/$short}"
|
||||
final="${tag2}-${short}"
|
||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||
- name: Extract Artifacts Version
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag/beta/$short}"
|
||||
final="${tag}-${short}"
|
||||
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
|
||||
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
||||
outputs:
|
||||
|
||||
@@ -67,6 +67,13 @@ jobs:
|
||||
username: ${{ secrets.docker_hub_username }}
|
||||
password: ${{ secrets.docker_hub_access_token }}
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -79,6 +86,8 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
|
||||
|
||||
- name: Build and push
|
||||
@@ -94,6 +103,8 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm64' }}
|
||||
|
||||
- name: Build and push
|
||||
@@ -109,4 +120,6 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm32v7' }}
|
||||
|
||||
@@ -10,8 +10,6 @@ jobs:
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -40,8 +38,6 @@ jobs:
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -58,7 +54,7 @@ jobs:
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-11
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
@@ -68,8 +64,6 @@ jobs:
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag/-beta/}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag}" >> $GITHUB_ENV
|
||||
- name: Extract Artifacts Version
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+309
-27
@@ -1,9 +1,289 @@
|
||||
# Changelog
|
||||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Add `linux-musl-x64` artifact for users running Alpine x64
|
||||
- Add API endpoint to empty trash (POST to `/api/maintenance/empty_trash`)
|
||||
- e.g. `curl -XPOST -d '' http://localhost:8409/api/maintenance/empty_trash`
|
||||
- Add remote IP and user agent to HTTP request logging
|
||||
- Add environment variables to allow ETV to run UI and streaming on separate ports
|
||||
- `ETV_STREAMING_PORT`: port used for streaming requests, defaults to 8409
|
||||
- `ETV_UI_PORT`: port used for admin UI, defaults to 8409
|
||||
- Publish docker images to ghcr.io (`ghcr.io/ersatztv/ersatztv`)
|
||||
- Add new option `Fixed Start Time Behavior` to Schedules and Schedule Items
|
||||
- Schedules can set a default behavior for all items
|
||||
- Schedule items can override this default behavior
|
||||
- Possible values are:
|
||||
- `Strict`: Always wait for the exact start time, even if that means waiting (adding unscheduled time) until the next day
|
||||
- `Flexible`: Start scheduling immediately (do not wait) if waiting (adding unscheduled time) would go into the next day
|
||||
- As an example, if the current scheduling time is 6:02 AM and the next schedule item has a fixed start time of 6:00 AM
|
||||
- `Strict` will add nearly 24h (23:58) of unscheduled time so that it can start exactly at 6:00 AM the next day
|
||||
- `Flexible` will NOT add unscheduled time, and will schedule its item at 6:02 AM (which may also affect the scheduling of later items)
|
||||
- Add basic HDR transcoding support
|
||||
- VAAPI may use hardware-accelerated tone mapping (when opencl accel is also available)
|
||||
- NVIDIA may use hardware-accelerated tone mapping (when vulkan accel and libplacebo filter are also available)
|
||||
- QSV may use hardware-accelerated tone mapping (when hardware decoding is used)
|
||||
- In all other cases, HDR content will use a software pipeline and the clip algorithm
|
||||
- Use hardware-accelerated padding with VAAPI
|
||||
|
||||
### Changed
|
||||
- Start to make UI minimally responsive (functional on smaller screens)
|
||||
- Change how ETV determines which address to use for Plex connections
|
||||
- The active Plex connection (address) will only be cached for 30 seconds
|
||||
- When the connection is no longer cached, a ping will be sent to the last used address for Plex (the last address that had a successful ping)
|
||||
- If the ping is successful, the address will be cached for another 30 seconds
|
||||
- If the ping is not successful, all addresses will be checked again, and the first address to return a successful ping will be cached for 30 seconds
|
||||
- Remove requirement to have Jellyfin admin user; user id is no longer required on requests to latest Jellyfin server
|
||||
- Upgrade bundled ffmpeg on Windows from 6.1 to 7.1.1
|
||||
- Upgrade VAAPI docker image Ubuntu base from 22 to 24; bundled ffmpeg from 6.1 to 7.1.1
|
||||
- Upgrade base, arm, arm64 docker images bundled ffmpeg from 6.1 to 7.1.1
|
||||
|
||||
### Fixed
|
||||
- Fix error message about synchronizing Plex collections from a Plex server that has zero collections
|
||||
- Fix navigation after form submission when using `ETV_BASE_URL` environment variable
|
||||
- Fix UI crashes when channel numbers contain a period `.` in locales that have a different decimal separator (e.g. `,`)
|
||||
- Fix playout detail table to only reload once when resetting a playout
|
||||
- Fix date formatting in playout detail table on reload (will now respect browser's `Accept-Language` header)
|
||||
- Use cache busting to avoid UI errors after upgrading the MudBlazor library
|
||||
- Fix multi-variant playlist to report more accurate `BANDWIDTH` value based on ffmpeg profile
|
||||
- Fix detecting NVIDIA capabilities on Blackwell GPUs
|
||||
|
||||
## [25.1.0] - 2025-01-10
|
||||
### Added
|
||||
- Add `Reset All Playouts` button to top of playouts page
|
||||
- Add `rewind_on_reset` option to `wait_until` YAML playout instruction
|
||||
- This option allows YAML playouts to start in the past
|
||||
- Add `advance` option to `epg_group` YAML playout instruction
|
||||
- When set to `false`, this option will lock the guide group without starting a new guide group
|
||||
- This can be helpful for "post roll" items that should be part of the previous item's guide group
|
||||
- Add `Song Video Mode` to channel settings
|
||||
- `Default` - existing behavior
|
||||
- `With Progress` - show animated progress bar at bottom of generated video
|
||||
- Thanks to @JeckDev for the idea and the artwork
|
||||
- Add fallback album art image for songs that have no album art
|
||||
- Add `Vaapi Display` option to FFmpeg Profile
|
||||
- Possible values will be install-specific and sourced from `vainfo`
|
||||
- `drm` was the previous default value, and should be used in most cases
|
||||
- Test all `Vaapi Display` values in `Troubleshooting` > `VAAPI Capabilities`
|
||||
- Add `tag_full` field to search index
|
||||
- This field contains the same values as the existing `tag` field, but it is not analyzed or tokenized
|
||||
|
||||
### Changed
|
||||
- **BREAKING CHANGE**: Change channel identifiers used in XMLTV to work around bad behavior in Plex
|
||||
|
||||
### Fixed
|
||||
- Fix startup error with MySql backend caused by database cleaner
|
||||
- Fix emptying trash with ElasticSearch backend
|
||||
- Fix double loading of trash UI elements, and fix reloading of all UI elements after emptying trash
|
||||
- Fix destroying channel preview player when preview dialog is closed
|
||||
- This bug made it difficult to "stop" a channel after previewing it
|
||||
- Fix bug where deco default filler would never use hardware acceleration
|
||||
- Fix deleting local libraries with MySql backend
|
||||
- Fix `Scaling Behavior` `Crop` when content is smaller than FFmpeg Profile resolution
|
||||
- Now, content will properly scale beyond the desired resolution before cropping
|
||||
- Fix displaying playout item durations that are greater than 24 hours
|
||||
- Fix building playouts when playlist has been changed to have fewer items
|
||||
- Fix selecting audio stream with preferred title
|
||||
- Fix synchronizing Plex collections
|
||||
- If this breaks collection sync for you, you will need to update your Plex server
|
||||
- Fix guide group generation for `duration` YAML instructions
|
||||
- Fix default song background when targeting 4:3 resolutions
|
||||
- Previously the background was always 16:9 and was padded, now it will fill 4:3
|
||||
|
||||
## [0.8.8-beta] - 2024-09-19
|
||||
### Added
|
||||
- Add support for Plex Other Video libraries
|
||||
- These libraries will now appear as ETV Other Video libraries
|
||||
- Items in these libraries will have tag metadata added from folders just like local Other Video libraries
|
||||
- Thanks @raknam for adding this feature!
|
||||
- Add *experimental* support for `On Demand` channel progress
|
||||
- With `On Demand` channel progress, the playout will only advance when the channel is being streamed
|
||||
- When the channel is idle, the playout is unmodified and will be shifted forward as needed so no content is missed
|
||||
- Setting a channel to `On Demand` progress will disable alternate schedules
|
||||
- The `On Demand` setting will only be used for `Flood` playouts (NOT `Block` or `External JSON`)
|
||||
- It is NOT recommended to use fixed start times with `On Demand` progress
|
||||
- This will probably be disabled with a future update
|
||||
- Add `Default Filler` to `Deco` system
|
||||
- After all blocks are scheduled/added to the playout, a second pass will be made to insert filler
|
||||
- Default filler will be shuffled and inserted in all unscheduled time between blocks
|
||||
- Default filler will stop scheduling when the next item would extend into primary content
|
||||
- Alternatively, default filler can be configured to `Trim To Fit`
|
||||
- In this case, the last item that would extend into primary content is trimmed to end exactly when the primary content starts
|
||||
- Add **experimental** playout type `YAML`
|
||||
- This playout type uses a YAML file to declare content and describe how the playout should be built
|
||||
- Content currently supports search queries
|
||||
- Playout instructions currently include `count`, `pad to next`, and `repeat`
|
||||
- `count`: add the specified number of items (from the referenced content) to the playout
|
||||
- `duration`: play the referenced content for the specified duration
|
||||
- `pad to next`: add items from the referenced content until the wall clock is a multiple of the specified minutes value
|
||||
- `repeat`: continue building the playout from the first instruction in the YAML file
|
||||
- Add channel logo generation by @raknam
|
||||
- Channels without custom uploaded logos will automatically generate a logo that includes the channel name
|
||||
- Add two new API endpoints
|
||||
- Reset playout for channel
|
||||
- POST `/api/channels/{channelNumber}/playout/reset`
|
||||
- Scan library
|
||||
- POST `/api/libraries/{libraryId}/scan`
|
||||
- Add Deco setting to `Use Watermark During Filler`
|
||||
- This setting is turned OFF by default, meaning filler will NOT use the configured watermark unless this is manually turned on
|
||||
- Add `Random Count` filler mode by @embolon
|
||||
- This mode will randomly schedule between zero and the provided count number of items
|
||||
- e.g. random count 3 will schedule between 0 and 3 filler items
|
||||
- Add `Random Rotation` playback order for block scheduling by @embolon
|
||||
- This playback order will pick a random item from a randomly selected group (show or artist)
|
||||
- It is somewhat similar to the `Fill With Group` mode used in flood scheduling
|
||||
|
||||
### Fixed
|
||||
- Add basic cache busting to XMLTV image URLs
|
||||
- This should help with clients not showing correct channel logos or posters
|
||||
- Fix artwork in other video libraries by @raknam
|
||||
- Fix adding items to empty playlists
|
||||
- Fix filler preset editor and deco dead air fallback editor to only show supported collection types
|
||||
- Fix infinite loop caused by impossible schedule (all collection items longer than schedule item duration)
|
||||
- Fix selecting audio and subtitle streams with two-letter language codes
|
||||
- Fix adding pad filler to content that is less than one minute in duration
|
||||
- Generate unique identifier for virtual HDHomeRun tuner by @raknam
|
||||
- This allows a single Plex server to connect to multiple ETV instances
|
||||
- Include *all* language codes from media library in preferred audio and subtitle language options
|
||||
- Language codes where an English name cannot be found will be at the bottom of the list
|
||||
- Fix local libraries to detect external subtitle files with unrecognized language codes
|
||||
- Fix playback selection of subtitles with unrecognized language codes
|
||||
- Fix incorrectly removing block items that are hidden from EPG when deco filler is applied
|
||||
- Fix deco selection when deco is scheduled until midnight
|
||||
- Previously, this deco item would be ignored so watermark and filler would be missing
|
||||
- Fix movies with missing medata by generating fallback metadata
|
||||
- This allows these movies to appear in the Trash where they can be deleted
|
||||
- Fix synchronizing trakt lists from users with special characters in their username
|
||||
- Note that these lists MUST be added as URLs; the short-form `user/list` will NOT work with special characters
|
||||
- Fix local subtitle scanner to detect non-lowercase extensions (e.g. `Movie (2000).EN.SRT`)
|
||||
- Fix adding a single image to a manual collection from search results
|
||||
- Fix loading manual collection view when collection contains images
|
||||
- Fix edge case where block playout history would get stuck and repeat an item
|
||||
- Fix adjusting watermark opacity when watermark already contains alpha channel (is already transparent)
|
||||
|
||||
### Changed
|
||||
- Remove some unnecessary API calls related to media server scanning and paging
|
||||
- Improve trakt list URL validation; non-trakt URLs will no longer be requested
|
||||
- Prevent saving block templates when blocks are overlapping
|
||||
- This can happen if block durations are changed for blocks that are already on the template
|
||||
- Redirect variant playlist request to proper URL for starting `HLS Segmenter` session when no session is active
|
||||
- This can happen when some clients "pause" long enough for the session to stop in ETV
|
||||
- When the client resumes playback, it requests the temp playlist URL which is now invalid e.g. `/iptv/session/1/hls.m3u8` (not the original URL `/iptv/channel/1.m3u8`)
|
||||
- To fix, the client will be redirected back to the original URL in this case which will create a new session
|
||||
|
||||
## [0.8.7-beta] - 2024-06-26
|
||||
### Added
|
||||
- Add `Active Date Range` to block playout template editor to allow limiting templates to a specific date range
|
||||
- This is year-agnostic, meaning the Month/Day range will apply to every year
|
||||
- This also supports wrapping the end of the year (e.g., start 12/1 and end 1/15)
|
||||
- Add new `Deco` system for "decorating" channels with non-primary content
|
||||
- Decos currently contain
|
||||
- Watermarks
|
||||
- Dead Air Fallback (i.e. fallback filler)
|
||||
- Similar to blocks, decos have deco groups for organization
|
||||
- Similar to blocks, decos have deco templates for filling a "day" with decos
|
||||
- In the playout template editor, playout template items can have *both* a block template and a deco template
|
||||
- This allows watermarks and dead air fallback to change at different times than primary content
|
||||
- Block playouts can also have a default deco
|
||||
- This will apply whenever a deco template is missing, or when a deco template item cannot be found for the current time
|
||||
- Effectively, this sets a default watermark and dead air fallback for the entire playout
|
||||
- Add `XMLTV Days To Build` setting, which is distinct from the existing `Playout Days To Build` setting
|
||||
- The value for `XMLTV Days To Build` cannot be larger than `Playout Days To Build`
|
||||
- This allows, for example, a week of playout data while optimizing XMLTV data to only a day or two
|
||||
- Add health check to detect config folder issue on MacOS
|
||||
- ETV versions through v0.8.4-beta (using dotnet 7) stored config data in `$HOME/.local/share/ersatztv`
|
||||
- ETV versions starting with v0.8.5-beta (using dotnet 8) store config data in `$HOME/Library/Application Support/ersatztv`
|
||||
- If a dotnet 8 version of ETV has NOT been launched on MacOS, it will automatically migrate the config folder on startup
|
||||
- If a dotnet 8 version of ETV *has* been launched on MacOS, a failing health check will display with instructions on how to resolve the config issue to restore data
|
||||
- Add `Video Profile` setting to `FFmpeg Profile` editor when `h264` format is selected
|
||||
- Add `Video Preset` setting to `FFmpeg Profile` editor for some combinations of acceleration and video format:
|
||||
- `Nvenc` / `h264`
|
||||
- `Nvenc` / `hevc`
|
||||
- `Qsv` / `h264`
|
||||
- `Qsv` / `hevc`
|
||||
- `None` / `h264`
|
||||
- `None` / `hevc`
|
||||
- Add *experimental* list type `Playlist`
|
||||
- Playlists contain an ordered list of:
|
||||
- Collections
|
||||
- Multi-Collections
|
||||
- Smart Collections
|
||||
- TV Shows
|
||||
- TV Seasons
|
||||
- Artists
|
||||
- Movies
|
||||
- Episodes
|
||||
- Music Videos
|
||||
- Other Videos
|
||||
- Songs
|
||||
- Images
|
||||
- Playlists can be added to schedules as a schedule item
|
||||
- Each time through the playlist, one item will be scheduled from each playlist item (if `Play All` is unchecked)
|
||||
- NB: This does not mean every collection will always schedule one item; the normal flood playout restrictions like duration and fixed start times still apply here
|
||||
- If `Play All` is checked, that playlist item will play all of its items each time through the playlist
|
||||
- This can be helpful if you want to play entire collections in a specific order, e.g.
|
||||
- Every episode from Show 1 Season 2
|
||||
- Every episode from Show 2 Season 3
|
||||
- Every episode from Show 1 Season 3
|
||||
- Playlist items with fewer media items will be re-shuffled (if applicable) before those with more media items
|
||||
- Add two new environment variables to customize config and transcode folder locations
|
||||
- `ETV_CONFIG_FOLDER`
|
||||
- `ETV_TRANSCODE_FOLDER`
|
||||
- Add checkbox to allow use of B-frames in FFmpeg Profile (disabled by default)
|
||||
|
||||
### Fixed
|
||||
- Fix some cases of 404s from Plex when files were replaced and scanning the library from ETV didn't help
|
||||
- Fix more wildcard search phrase queries (when wildcards are used in quotes, like `title:"law & order*"`)
|
||||
- Fix non-wildcard simple queries when asterisks are used in quotes, like `title:"m*a*s*h"`
|
||||
- Fix bug where channels would unnecessarily wait on each other
|
||||
- e.g. in-progress streams would delay responding with a playlist when new streams were starting
|
||||
- Update Plex show title in ETV when changed in Plex
|
||||
- Reindex seasons and episodes when show is updated from media server
|
||||
- This is needed to keep `show_*` tags accurate in the search index (e.g., `show_title`, `show_studio`)
|
||||
- Fix external subtitle detection to support forced/sdh subtitles with language tag before and after forced/sdh tag:
|
||||
- `Something.forced.en.srt`
|
||||
- `Something.sdh.en.srt`
|
||||
- `Something.en.forced.srt`
|
||||
- `Something.en.sdh.srt`
|
||||
- Fix playback from Jellyfin 10.9 by allowing playlist HTTP HEAD requests
|
||||
- Fix `HLS Segmenter V2` segment duration (previously 10s, now 4s)
|
||||
- Fix `HLS Segmenter V2` error video generation
|
||||
- Fix MySql database migrations
|
||||
- Fix Plex library scans with MySql/MariaDB
|
||||
- Fix block playout playback when no deco is configured
|
||||
- Fix `HLS Segmenter V2` to delete old segments (use less disk space while channel is active)
|
||||
- Fix template and deco template editors to prevent items that go beyond midnight
|
||||
- Fix block playout random seeds
|
||||
- Different blocks within a single playout will now correctly use different random seeds (shuffles)
|
||||
- Erasing block playout history will also generate new random seeds for the playout
|
||||
- Fix building playouts that use mid-roll filler and have content without chapter markers
|
||||
- When this happens, mid-roll will be treated as post-roll
|
||||
- Fix VAAPI decoder capability check
|
||||
- This caused some streams to incorrectly use software decoding
|
||||
- Fix scheduling loop/failure caused by some duration schedule items
|
||||
- Fix `video_bit_depth` search field for Plex media
|
||||
- Fix template and deco template editors with MariaDB/MySql backend
|
||||
- Fix transcoding 10-bit source content using QSV acceleration on Windows
|
||||
|
||||
### Changed
|
||||
- Show health checks at top of home page; scroll release notes if needed
|
||||
- Improve `HLS Segmenter V2` compliance by:
|
||||
- Serving fmp4 segments when `hevc` video format is selected
|
||||
- > 1.5. The container format for HEVC video MUST be fMP4.
|
||||
- Using accurate BANDWIDTH value in multi-variant playlist
|
||||
- Using proper MIME types for statically-served `.m3u8` and `.ts` files
|
||||
- Serving playlists with gzip compression
|
||||
- Use `HLS Segmenter V2` for channel preview when channel is configured for `HLS Segmenter V2`
|
||||
- Detect and use `/dev/dri/card*` devices in addition to `/dev/dri/render*` devices
|
||||
- Change default folder locations in docker using new environment variables
|
||||
- `ETV_CONFIG_FOLDER` - now defaults to `/config`
|
||||
- `ETV_TRANSCODE_FOLDER` - now defaults to `/transcode`
|
||||
- If the old locations are still present in docker, these variables will be ignored, so you can migrate at your own pace
|
||||
- Old config location: `/root/.local/share/ersatztv`
|
||||
- Old transcode location: `/root/.local/share/etv-transcode`
|
||||
|
||||
## [0.8.6-beta] - 2024-04-03
|
||||
### Added
|
||||
@@ -44,8 +324,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Fix bug applying music video fallback metadata
|
||||
- Fix playback of media items with no audio streams
|
||||
- Fix timestamp continuity in `HLS Segmenter` sessions
|
||||
- This should make *some* clients happier
|
||||
- Fix `Other Video`, `Song` and `Image` fallback metadata tags to always include parent folder (folder added to library)
|
||||
- This should make *some* clients happier
|
||||
- Fix `Other Video`, `Song` and `Image` fallback metadata tags to always include parent folder (folder added to library)
|
||||
- Allow playback of items with any positive duration, including less than one second
|
||||
- Fix VAAPI transcoding of OTA content containing A53 CC data
|
||||
- Fix AV1 software decoder priority (`libdav1d`, `libaom-av1`, `av1`)
|
||||
@@ -101,11 +381,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- GET `/api/sessions`
|
||||
- Show brief info about all active sessions
|
||||
- DELETE `/api/session/{channel-number}`
|
||||
- Stop the session for the given channel number
|
||||
- Stop the session for the given channel number
|
||||
- Add channel preview (web-based video player)
|
||||
- Channels MUST use `H264` video format and `AAC` audio format
|
||||
- Channels MUST use `MPEG-TS` or `HLS Segmenter` streaming modes
|
||||
- Since `MPEG-TS` uses `HLS Segmenter` under the hood, the preview player will use `HLS Segmenter`, so it's not 100% equivalent, but it should be representative
|
||||
- Since `MPEG-TS` uses `HLS Segmenter` under the hood, the preview player will use `HLS Segmenter`, so it's not 100% equivalent, but it should be representative
|
||||
- Add button to stop transcoding session for each channel that has an active session
|
||||
- Add more log levels to `Settings` page, allowing more specific debug logging as needed
|
||||
- Default Minimum Log Level (applies when no other categories/level overrides match)
|
||||
@@ -121,10 +401,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Update show year when changed within Plex
|
||||
- Fix crop scale behavior with NVIDIA, QSV acceleration
|
||||
- Fix bug that corrupted uploaded images (watermarks, channel logos)
|
||||
- Re-uploading images should fix them
|
||||
- Re-uploading images should fix them
|
||||
- Recreate XMLTV channel list (including logos) when channels are edited in ErsatzTV
|
||||
- This bug caused the ErsatzTV logo to be used instead of channel logos in some cases
|
||||
- Update drop down search results in main search bar when items are created/edited/removed
|
||||
- This bug caused the ErsatzTV logo to be used instead of channel logos in some cases
|
||||
- Update drop down search results in main search bar when items are created/edited/removed
|
||||
- Fix green line at bottom of video when NVIDIA accel is used with intermittent watermark
|
||||
- Fix error starting streaming session when subtitles are still being extracted for the current item
|
||||
|
||||
@@ -244,7 +524,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
### Fixed
|
||||
- Only allow a single instance of ErsatzTV to run
|
||||
- This fixes some cases where the search index would become unusable
|
||||
- Fix VAAPI rate control mode capability check
|
||||
- Fix VAAPI rate control mode capability check
|
||||
|
||||
### Changed
|
||||
- Rework startup process to show UI as early as possible
|
||||
@@ -290,14 +570,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `MKV` output format: stream copy any embedded subtitles
|
||||
|
||||
### Fixed
|
||||
- Fix extracting embedded text subtitles that had been incompletely extracted in the past
|
||||
- Fix extracting embedded text subtitles that had been incompletely extracted in the past
|
||||
- Fix fallback filler looping by forcing software mode for this content
|
||||
- Other content will still use hardware acceleration as configured
|
||||
- Hardware-accelerated fallback filler may be re-enabled in the future
|
||||
- Fix playout building when shuffle in order is used with a single media item
|
||||
- Fix pgs subtitle burn in from media server libraries
|
||||
- Fix subtitle and watermark overlays with RadeonSI VAAPI driver
|
||||
- Fix NVIDIA pipeline to use hardware-accelerated decoder with 8-bit h264 content
|
||||
- Fix NVIDIA pipeline to use hardware-accelerated decoder with 8-bit h264 content
|
||||
|
||||
### Changed
|
||||
- Timeout playout builds after 2 minutes; this should prevent playout bugs from blocking other functionality
|
||||
@@ -347,7 +627,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
### Changed
|
||||
- Use Poster artwork for XMLTV if available
|
||||
- If Poster artwork is unavailable, use Thumbnail
|
||||
- Improve XMLTV response time by caching data as playouts are updated
|
||||
- Improve XMLTV response time by caching data as playouts are updated
|
||||
|
||||
## [0.7.6-beta] - 2023-03-24
|
||||
### Added
|
||||
@@ -441,13 +721,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
### Changed
|
||||
- Merge generated `Other Video` folder tags with tags from sidecar NFO
|
||||
- Prioritize audio streams that are flagged as "default" when multiple candidate streams are available
|
||||
- For example, a video with a stereo commentary track and a stereo "default" track will now prefer the "default" track
|
||||
- For example, a video with a stereo commentary track and a stereo "default" track will now prefer the "default" track
|
||||
|
||||
## [0.7.3-beta] - 2023-01-25
|
||||
### Added
|
||||
- Attempt to release memory periodically
|
||||
- Add OpenID Connect (OIDC) support (e.g. Keycloak, Authelia, Auth0)
|
||||
- This only protects the management UI; all streaming endpoints will continue to allow anonymous access
|
||||
- This only protects the management UI; all streaming endpoints will continue to allow anonymous access
|
||||
- This can be configured with the following env vars (note the double underscore separator `__`)
|
||||
- `OIDC__AUTHORITY`
|
||||
- `OIDC__CLIENTID`
|
||||
@@ -465,7 +745,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Day of week
|
||||
- Day of month
|
||||
- Month
|
||||
- The lowest priority (bottom) item will always match all parameters, and can be considered a "default" or "fallback" schedule
|
||||
- The lowest priority (bottom) item will always match all parameters, and can be considered a "default" or "fallback" schedule
|
||||
|
||||
### Fixed
|
||||
- Fix schedule editor crashing due to bad music video artist data
|
||||
@@ -497,7 +777,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Fix song playback with VAAPI and NVENC
|
||||
- Fix edge case where some local movies would not automatically be restored from trash
|
||||
- Fix synchronizing Jellyfin and Emby collection items
|
||||
- Fix saving some external subtitle records to database
|
||||
- Fix saving some external subtitle records to database
|
||||
|
||||
### Changed
|
||||
- Upgrade to dotnet 7
|
||||
@@ -575,7 +855,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
### Added
|
||||
- Add music video credits template system
|
||||
- Templates are selected in each channel's settings
|
||||
- Templates are selected in each channel's settings
|
||||
- Templates should be copied from `_default.ass.sbntxt` which is located in the config subfolder `templates/music-video-credits`
|
||||
- Copy the file, give it any name ending with `.ass.sbntext`, and only make edits to the copied file
|
||||
- The default template will be extracted and overwritten every time ErsatzTV is started
|
||||
@@ -650,7 +930,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Replace `setsar` filter with `setdar` filter
|
||||
- `setsar` caused issues scaling between two different aspect ratios
|
||||
- For example, some 4:3 content would appear stretched when scaled to a 16:9 resolution
|
||||
- `setdar` is now only used when aspect ratios match
|
||||
- `setdar` is now only used when aspect ratios match
|
||||
- Prioritize aspect ratio from container when video stream contains conflicting aspect ratio
|
||||
- This is usually caused by bad authoring, but the change should improve scaling behavior for edge cases
|
||||
|
||||
@@ -753,7 +1033,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Reduce memory use due to library scan operations
|
||||
- Fix some instances of filler getting "stuck" when a filler item is encountered that's too long for the gap
|
||||
- Properly ignore Plex `Other Videos` libraries (`movie` libraries where agent is `com.plexapp.agents.none`)
|
||||
- Fix `Custom Title` for schedule items with `One`, `Multiple` and `Flood` playout modes
|
||||
- Fix `Custom Title` for schedule items with `One`, `Multiple` and `Flood` playout modes
|
||||
- Fix scheduling bug where flood items would sometimes fail to continue after midnight
|
||||
|
||||
### Added
|
||||
@@ -783,7 +1063,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Fix occasional erroneous log messages when HLS channel playback times out because all clients have left
|
||||
- Fix fallback filler playback
|
||||
- Fix stream continuity when error messages are displayed
|
||||
- Fix duplicate scanning within `Other Video` libraries (i.e. folders would be scanned multiple times)
|
||||
- Fix duplicate scanning within `Other Video` libraries (i.e. folders would be scanned multiple times)
|
||||
|
||||
### Added
|
||||
- Add `show_genre` and `show_tag` to search index for seasons and episodes
|
||||
@@ -871,7 +1151,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Fix setting VAAPI driver name
|
||||
- Fix ffmpeg troubleshooting reports
|
||||
- Fix bug where filler would behave as if it were configured to pad even though a different mode was selected
|
||||
- Fix bug where mid-roll count filler would skip scheduling the final chapter in an episode
|
||||
- Fix bug where mid-roll count filler would skip scheduling the final chapter in an episode
|
||||
|
||||
### Added
|
||||
- Add `Empty Trash` button to `Trash` page
|
||||
@@ -1178,7 +1458,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- When streaming is attempted during an unscheduled gap, the resulting video will be determined using the following priority:
|
||||
- Channel fallback filler
|
||||
- Global fallback filler
|
||||
- Generated `Channel Is Offline` error message video
|
||||
- Generated `Channel Is Offline` error message video
|
||||
|
||||
### Changed
|
||||
- Allow per-episode folders for local show libraries
|
||||
@@ -1286,7 +1566,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [0.1.0-alpha] - 2021-10-08
|
||||
### Added
|
||||
- Add *experimental* streaming mode `HLS Segmenter` (most similar to `HLS Hybrid`)
|
||||
- Add *experimental* streaming mode `HLS Segmenter` (most similar to `HLS Hybrid`)
|
||||
- This mode is intended to increase client compatibility and reduce issues at program boundaries
|
||||
- If you want the temporary transcode files to be located on a particular drive, the docker path is `/root/.local/share/etv-transcode`
|
||||
- Store frame rate with media statistics; this is needed to support HLS Segmenter
|
||||
@@ -1322,7 +1602,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- See `Logs` for unmatched item details
|
||||
- Trakt lists can only be scheduled by using Smart Collections
|
||||
- Add seasons to search index
|
||||
- This is needed because Trakt lists can contain seasons
|
||||
- This is needed because Trakt lists can contain seasons
|
||||
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
|
||||
|
||||
### Fixed
|
||||
@@ -1674,7 +1954,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Limit channels to one playout per channel
|
||||
- Though more than one playout was previously possible it was unsupported and unlikely to work as expected, if at all
|
||||
- A future release may make this possible again
|
||||
|
||||
|
||||
## [0.0.32-prealpha] - 2021-04-09
|
||||
### Added
|
||||
- `Add All To Collection` button to quickly add all search results to a collection
|
||||
@@ -1939,7 +2219,9 @@ 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/ErsatzTV/ErsatzTV/compare/v0.8.6-beta...HEAD
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.8-beta...HEAD
|
||||
[0.8.8-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.7-beta...v0.8.8-beta
|
||||
[0.8.7-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.6-beta...v0.8.7-beta
|
||||
[0.8.6-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.5-beta...v0.8.6-beta
|
||||
[0.8.5-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.4-beta...v0.8.5-beta
|
||||
[0.8.4-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.3-beta...v0.8.4-beta
|
||||
@@ -2062,4 +2344,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
[0.0.5-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.4-prealpha...v0.0.5-prealpha
|
||||
[0.0.4-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.3-prealpha...v0.0.4-prealpha
|
||||
[0.0.3-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.1-prealpha...v0.0.3-prealpha
|
||||
[0.0.1-prealpha]: https://github.com/ErsatzTV/ErsatzTV/releases/tag/v0.0.1-prealpha
|
||||
[0.0.1-prealpha]: https://github.com/ErsatzTV/ErsatzTV/releases/tag/v0.0.1-prealpha
|
||||
+1
-1
Submodule ErsatzTV-macOS updated: 2f3ee16f11...bf18a5e4a4
@@ -24,7 +24,7 @@ public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<Artist
|
||||
async artist =>
|
||||
{
|
||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = await _searchRepository.GetAllLanguageCodes(mediaCodes);
|
||||
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
|
||||
return ProjectToViewModel(artist, languageCodes);
|
||||
},
|
||||
() => Task.FromResult(Option<ArtistViewModel>.None));
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
public record GetArtwork(int Id) : IRequest<Either<BaseError, Artwork>>;
|
||||
@@ -0,0 +1,42 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory) : IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, Artwork>> Handle(
|
||||
GetArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try {
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Artwork> artwork = await dbContext.Artwork
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.Id)
|
||||
.MapT(Project);
|
||||
|
||||
return artwork.ToEither(BaseError.New("Artwork not found"));
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static Artwork Project(Artwork artwork)
|
||||
{
|
||||
return new Artwork {
|
||||
Id = artwork.Id,
|
||||
Path = artwork.Path,
|
||||
ArtworkKind = artwork.ArtworkKind
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using System.Net;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
@@ -12,6 +13,7 @@ public record ChannelViewModel(
|
||||
string Logo,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -19,4 +21,8 @@ public record ChannelViewModel(
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate);
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode)
|
||||
{
|
||||
public string WebEncodedName => WebUtility.UrlEncode(Name);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ public record CreateChannel(
|
||||
string Logo,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -39,8 +38,6 @@ public class CreateChannelHandler(
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
|
||||
(ValidateName(request), await ValidateNumber(dbContext, request),
|
||||
await FFmpegProfileMustExist(dbContext, request),
|
||||
ValidatePreferredAudioLanguage(request),
|
||||
ValidatePreferredSubtitleLanguage(request),
|
||||
await WatermarkMustExist(dbContext, request),
|
||||
await FillerPresetMustExist(dbContext, request))
|
||||
.Apply(
|
||||
@@ -48,8 +45,6 @@ public class CreateChannelHandler(
|
||||
name,
|
||||
number,
|
||||
ffmpegProfileId,
|
||||
preferredAudioLanguageCode,
|
||||
preferredSubtitleLanguageCode,
|
||||
watermarkId,
|
||||
fillerPresetId) =>
|
||||
{
|
||||
@@ -73,14 +68,16 @@ public class CreateChannelHandler(
|
||||
Group = request.Group,
|
||||
Categories = request.Categories,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
ProgressMode = request.ProgressMode,
|
||||
StreamingMode = request.StreamingMode,
|
||||
Artwork = artwork,
|
||||
PreferredAudioLanguageCode = preferredAudioLanguageCode,
|
||||
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle = request.PreferredAudioTitle,
|
||||
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
|
||||
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
|
||||
SubtitleMode = request.SubtitleMode,
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate
|
||||
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
|
||||
SongVideoMode = request.SongVideoMode
|
||||
};
|
||||
|
||||
foreach (int id in watermarkId)
|
||||
@@ -100,20 +97,6 @@ public class CreateChannelHandler(
|
||||
createChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
private static Validation<BaseError, string> ValidatePreferredAudioLanguage(CreateChannel createChannel) =>
|
||||
Optional(createChannel.PreferredAudioLanguageCode ?? string.Empty)
|
||||
.Filter(
|
||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToValidation<BaseError>("Preferred audio language code is invalid");
|
||||
|
||||
private static Validation<BaseError, string> ValidatePreferredSubtitleLanguage(CreateChannel createChannel) =>
|
||||
Optional(createChannel.PreferredSubtitleLanguageCode ?? string.Empty)
|
||||
.Filter(
|
||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToValidation<BaseError>("Preferred subtitle language code is invalid");
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
|
||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -171,12 +172,23 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
int daysToBuild = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
|
||||
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
switch (playout.ProgramSchedulePlayoutType)
|
||||
{
|
||||
case ProgramSchedulePlayoutType.Flood:
|
||||
var floodSorted = playouts.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList();
|
||||
case ProgramSchedulePlayoutType.Yaml:
|
||||
var floodSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
floodSorted,
|
||||
@@ -190,7 +202,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
xml);
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.Block:
|
||||
var blockSorted = playouts.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList();
|
||||
var blockSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
await WriteBlockPlayoutXml(
|
||||
request,
|
||||
blockSorted,
|
||||
@@ -204,7 +220,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
xml);
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.ExternalJson:
|
||||
List<PlayoutItem> externalJsonSorted = await CollectExternalJsonItems(playout.ExternalJsonFile);
|
||||
var externalJsonSorted = (await CollectExternalJsonItems(playout.ExternalJsonFile))
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
externalJsonSorted,
|
||||
@@ -268,7 +287,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.Tail or FillerKind.Fallback))
|
||||
or FillerKind.Tail or FillerKind.Fallback or FillerKind.DecoDefault))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
@@ -350,6 +369,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
|
||||
if (itemsToInclude.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime();
|
||||
@@ -491,6 +515,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
ProgrammeStop = stop,
|
||||
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
|
||||
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
|
||||
request.ChannelNumber,
|
||||
HasCustomTitle = hasCustomTitle,
|
||||
displayItem.CustomTitle,
|
||||
@@ -546,6 +572,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
ProgrammeStop = stop,
|
||||
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
|
||||
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
|
||||
request.ChannelNumber,
|
||||
HasCustomTitle = hasCustomTitle,
|
||||
displayItem.CustomTitle,
|
||||
@@ -606,6 +634,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
ProgrammeStop = stop,
|
||||
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
|
||||
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
|
||||
request.ChannelNumber,
|
||||
HasCustomTitle = hasCustomTitle,
|
||||
displayItem.CustomTitle,
|
||||
@@ -663,6 +693,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
ProgrammeStop = stop,
|
||||
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
|
||||
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
|
||||
request.ChannelNumber,
|
||||
HasCustomTitle = hasCustomTitle,
|
||||
displayItem.CustomTitle,
|
||||
@@ -715,6 +747,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
ProgrammeStop = stop,
|
||||
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
|
||||
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
|
||||
request.ChannelNumber,
|
||||
HasCustomTitle = hasCustomTitle,
|
||||
displayItem.CustomTitle,
|
||||
@@ -934,68 +968,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDescription(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => mvm.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private Option<ContentRating> GetContentRating(PlayoutItem playoutItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata
|
||||
.HeadOrNone()
|
||||
.Match(mm => ParseContentRating(mm.ContentRating, "MPAA"), () => None),
|
||||
Episode e => e.Season.Show.ShowMetadata
|
||||
.HeadOrNone()
|
||||
.Match(sm => ParseContentRating(sm.ContentRating, "VCHIP"), () => None),
|
||||
_ => None
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
|
||||
{
|
||||
Option<string> maybeFirst = (contentRating ?? string.Empty).Split('/').HeadOrNone();
|
||||
return maybeFirst.Map(
|
||||
first =>
|
||||
{
|
||||
string[] split = first.Split(':');
|
||||
if (split.Length == 2)
|
||||
{
|
||||
return split[0].Equals("us", StringComparison.OrdinalIgnoreCase)
|
||||
? new ContentRating(system, split[1].ToUpperInvariant())
|
||||
: new ContentRating(None, split[1].ToUpperInvariant());
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(first)
|
||||
? Option<ContentRating>.None
|
||||
: new ContentRating(None, first);
|
||||
}).Flatten();
|
||||
}
|
||||
|
||||
private static string GetPrioritizedArtworkPath(Metadata metadata)
|
||||
{
|
||||
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||
@@ -1157,6 +1129,4 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record ContentRating(Option<string> System, string Value);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Data.Common;
|
||||
using System.Net;
|
||||
using System.Xml;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -77,11 +79,14 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
{
|
||||
var data = new
|
||||
{
|
||||
ChannelId = ChannelIdentifier.FromNumber(channel.Number),
|
||||
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(channel.Number),
|
||||
ChannelNumber = channel.Number,
|
||||
ChannelName = channel.Name,
|
||||
ChannelCategories = GetCategories(channel.Categories),
|
||||
ChannelHasArtwork = !string.IsNullOrWhiteSpace(channel.ArtworkPath),
|
||||
ChannelArtworkPath = channel.ArtworkPath
|
||||
ChannelArtworkPath = channel.ArtworkPath,
|
||||
ChannelNameEncoded = WebUtility.UrlEncode(channel.Name)
|
||||
};
|
||||
|
||||
var scriptObject = new ScriptObject();
|
||||
@@ -132,11 +137,6 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
private static string GetIconUrl(ChannelResult channel) =>
|
||||
string.IsNullOrWhiteSpace(channel.ArtworkPath)
|
||||
? "{RequestBase}/iptv/images/ersatztv-500.png{AccessTokenUri}"
|
||||
: $"{{RequestBase}}/iptv/logos/{channel.ArtworkPath}.jpg{{AccessTokenUri}}";
|
||||
|
||||
// ReSharper disable once ClassNeverInstantiated.Local
|
||||
private sealed record ChannelResult(string Number, string Name, string Categories, string ArtworkPath);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ public record UpdateChannel(
|
||||
string Logo,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -41,6 +41,7 @@ public class UpdateChannelHandler(
|
||||
c.SubtitleMode = update.SubtitleMode;
|
||||
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
|
||||
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
|
||||
c.SongVideoMode = update.SongVideoMode;
|
||||
c.Artwork ??= new List<Artwork>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo))
|
||||
@@ -67,6 +68,7 @@ public class UpdateChannelHandler(
|
||||
});
|
||||
}
|
||||
|
||||
c.ProgressMode = update.ProgressMode;
|
||||
c.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
@@ -92,9 +94,8 @@ public class UpdateChannelHandler(
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
|
||||
(await ChannelMustExist(dbContext, request), ValidateName(request),
|
||||
await ValidateNumber(dbContext, request),
|
||||
ValidatePreferredAudioLanguage(request))
|
||||
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
|
||||
await ValidateNumber(dbContext, request))
|
||||
.Apply((channelToUpdate, _, _) => channelToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
@@ -129,11 +130,4 @@ public class UpdateChannelHandler(
|
||||
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
private static Validation<BaseError, string> ValidatePreferredAudioLanguage(UpdateChannel updateChannel) =>
|
||||
Optional(updateChannel.PreferredAudioLanguageCode ?? string.Empty)
|
||||
.Filter(
|
||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToValidation<BaseError>("Preferred audio language code is invalid");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ internal static class Mapper
|
||||
GetLogo(channel),
|
||||
channel.PreferredAudioLanguageCode,
|
||||
channel.PreferredAudioTitle,
|
||||
channel.ProgressMode,
|
||||
channel.StreamingMode,
|
||||
channel.WatermarkId,
|
||||
channel.FallbackFillerId,
|
||||
@@ -23,7 +24,8 @@ internal static class Mapper
|
||||
channel.PreferredSubtitleLanguageCode,
|
||||
channel.SubtitleMode,
|
||||
channel.MusicVideoCreditsMode,
|
||||
channel.MusicVideoCreditsTemplate);
|
||||
channel.MusicVideoCreditsTemplate,
|
||||
channel.SongVideoMode);
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
@@ -37,6 +39,9 @@ internal static class Mapper
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
|
||||
internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) =>
|
||||
new(resolution.Height, resolution.Width, bitrate);
|
||||
|
||||
private static string GetLogo(Channel channel) =>
|
||||
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
|
||||
@@ -36,10 +36,12 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
string accessTokenUri = string.Empty;
|
||||
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
|
||||
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
{
|
||||
accessTokenUri = $"?access_token={request.AccessToken}";
|
||||
accessTokenUri += $"&access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelResolution(string ChannelNumber) : IRequest<Option<ResolutionViewModel>>;
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelResolutionAndBitrate(string ChannelNumber) : IRequest<Option<ResolutionAndBitrateViewModel>>;
|
||||
+7
-5
@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelResolutionHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelResolution, Option<ResolutionViewModel>>
|
||||
public class GetChannelResolutionAndBitrateHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelResolutionAndBitrate, Option<ResolutionAndBitrateViewModel>>
|
||||
{
|
||||
public async Task<Option<ResolutionViewModel>> Handle(
|
||||
GetChannelResolution request,
|
||||
public async Task<Option<ResolutionAndBitrateViewModel>> Handle(
|
||||
GetChannelResolutionAndBitrate request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
@@ -20,6 +20,8 @@ public class GetChannelResolutionHandler(IDbContextFactory<TvContext> dbContextF
|
||||
.ThenInclude(ff => ff.Resolution)
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
|
||||
|
||||
return maybeChannel.Map(c => Mapper.ProjectToViewModel(c.FFmpegProfile.Resolution));
|
||||
return maybeChannel.Map(c => Mapper.ProjectToViewModel(
|
||||
c.FFmpegProfile.Resolution,
|
||||
(int)((c.FFmpegProfile.VideoBitrate * 1000 + c.FFmpegProfile.AudioBitrate * 1000) * 1.2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record ResolutionAndBitrateViewModel(int Height, int Width, int Bitrate);
|
||||
@@ -16,11 +16,26 @@ public class UpdateXmltvSettingsHandler(
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateXmltvSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.XmltvSettings);
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int playoutDaysToBuild =
|
||||
await configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
if (playoutDaysToBuild < request.XmltvSettings.DaysToBuild)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"XMLTV days to build ({request.XmltvSettings.DaysToBuild}) cannot be greater than Playout days to build ({playoutDaysToBuild})");
|
||||
}
|
||||
|
||||
return await ApplyUpdate(request.XmltvSettings);
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings)
|
||||
{
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
|
||||
@@ -8,11 +8,14 @@ public class GetXmltvSettingsHandler(IConfigElementRepository configElementRepos
|
||||
{
|
||||
public async Task<XmltvSettingsViewModel> Handle(GetXmltvSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> daysToBuild = await configElementRepository.GetValue<int>(ConfigElementKey.XmltvDaysToBuild);
|
||||
|
||||
Option<XmltvTimeZone> maybeTimeZone =
|
||||
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone);
|
||||
|
||||
return new XmltvSettingsViewModel
|
||||
{
|
||||
DaysToBuild = await daysToBuild.IfNoneAsync(2),
|
||||
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class XmltvSettingsViewModel
|
||||
{
|
||||
public int DaysToBuild { get; set; }
|
||||
public XmltvTimeZone TimeZone { get; set; }
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
|
||||
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby();
|
||||
foreach (EmbyMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
// await _channel.WriteAsync(new SynchronizeEmbyAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,19 +9,19 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.6" />
|
||||
<PackageReference Include="Bugsnag" Version="4.0.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.8.2" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.9.28">
|
||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="3.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -8,12 +8,16 @@ public record CreateFFmpegProfile(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
bool AllowBFrames,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
|
||||
@@ -54,6 +54,9 @@ public class CreateFFmpegProfileHandler :
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
VideoFormat = request.VideoFormat,
|
||||
VideoProfile = request.VideoProfile,
|
||||
VideoPreset = request.VideoPreset,
|
||||
AllowBFrames = request.AllowBFrames,
|
||||
BitDepth = request.BitDepth,
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
|
||||
@@ -9,12 +9,16 @@ public record UpdateFFmpegProfile(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
bool AllowBFrames,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
|
||||
@@ -36,12 +36,16 @@ public class
|
||||
p.Name = update.Name;
|
||||
p.ThreadCount = update.ThreadCount;
|
||||
p.HardwareAcceleration = update.HardwareAcceleration;
|
||||
p.VaapiDisplay = update.VaapiDisplay;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.ScalingBehavior = update.ScalingBehavior;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
p.VideoProfile = update.VideoProfile;
|
||||
p.VideoPreset = update.VideoPreset;
|
||||
p.AllowBFrames = update.AllowBFrames;
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
p.BitDepth = update.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video
|
||||
|
||||
@@ -9,12 +9,16 @@ public record FFmpegProfileViewModel(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
ResolutionViewModel Resolution,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
bool AllowBFrames,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
|
||||
@@ -11,12 +11,16 @@ internal static class Mapper
|
||||
profile.Name,
|
||||
profile.ThreadCount,
|
||||
profile.HardwareAcceleration,
|
||||
profile.VaapiDisplay ?? "drm",
|
||||
profile.VaapiDriver,
|
||||
profile.VaapiDevice,
|
||||
profile.QsvExtraHardwareFrames,
|
||||
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
|
||||
profile.ScalingBehavior,
|
||||
profile.VideoFormat,
|
||||
profile.VideoProfile,
|
||||
profile.VideoPreset ?? string.Empty,
|
||||
profile.AllowBFrames,
|
||||
profile.BitDepth,
|
||||
profile.VideoBitrate,
|
||||
profile.VideoBufferSize,
|
||||
@@ -43,6 +47,7 @@ internal static class Mapper
|
||||
ffmpegProfile.Name,
|
||||
ffmpegProfile.ThreadCount,
|
||||
(int)ffmpegProfile.HardwareAcceleration,
|
||||
ffmpegProfile.VaapiDisplay,
|
||||
(int)ffmpegProfile.VaapiDriver,
|
||||
ffmpegProfile.VaapiDevice,
|
||||
ffmpegProfile.ResolutionId,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.HDHR;
|
||||
|
||||
public record GetHDHRUUID : IRequest<Guid>;
|
||||
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.HDHR;
|
||||
|
||||
public class GetHDHRUUIDHandler : IRequestHandler<GetHDHRUUID, Guid>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetHDHRUUIDHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<Guid> Handle(GetHDHRUUID request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeGuid = await _configElementRepository.GetValue<Guid>(ConfigElementKey.HDHRUUID);
|
||||
return await maybeGuid.IfNoneAsync(
|
||||
async () =>
|
||||
{
|
||||
Guid guid = Guid.NewGuid();
|
||||
await _configElementRepository.Upsert(ConfigElementKey.HDHRUUID, guid);
|
||||
return guid;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinAdminUserId(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
@@ -1,107 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class
|
||||
SynchronizeJellyfinAdminUserIdHandler : IRequestHandler<SynchronizeJellyfinAdminUserId,
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly ILogger<SynchronizeJellyfinAdminUserIdHandler> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public SynchronizeJellyfinAdminUserIdHandler(
|
||||
IMemoryCache memoryCache,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
ILogger<SynchronizeJellyfinAdminUserIdHandler> logger)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
SynchronizeJellyfinAdminUserId request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.Map(v => v.ToEither<ConnectionParameters>())
|
||||
.BindT(PerformSync);
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformSync(ConnectionParameters parameters)
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{parameters.JellyfinMediaSource.Id}", out string _))
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
Either<BaseError, string> maybeUserId = await _jellyfinApiClient.GetAdminUserId(
|
||||
parameters.ActiveConnection.Address,
|
||||
parameters.ApiKey);
|
||||
|
||||
return await maybeUserId.Match(
|
||||
userId =>
|
||||
{
|
||||
// _logger.LogDebug("Jellyfin admin user id is {UserId}", userId);
|
||||
_memoryCache.Set($"jellyfin_admin_user_id.{parameters.JellyfinMediaSource.Id}", userId);
|
||||
return Task.FromResult<Either<BaseError, Unit>>(Unit.Default);
|
||||
},
|
||||
async error =>
|
||||
{
|
||||
// clear api key if unable to sync with jellyfin
|
||||
if (error.Value.Contains("Unauthorized"))
|
||||
{
|
||||
await _jellyfinSecretStore.SaveSecrets(
|
||||
new JellyfinSecrets { Address = parameters.ActiveConnection.Address, ApiKey = null });
|
||||
}
|
||||
|
||||
return Left<BaseError, Unit>(error);
|
||||
});
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinAdminUserId request) =>
|
||||
MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> MediaSourceMustExist(
|
||||
SynchronizeJellyfinAdminUserId request) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
.Where(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private sealed record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,6 @@ public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<Synchroniz
|
||||
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
|
||||
foreach (JellyfinMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinAdminUserId(mediaSource.Id),
|
||||
cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,9 +47,12 @@ public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
"""
|
||||
DELETE FROM MediaItem WHERE Id IN
|
||||
(
|
||||
SELECT MI.Id FROM MediaItem MI
|
||||
INNER JOIN LibraryPath LP ON MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId
|
||||
SELECT Id FROM
|
||||
(
|
||||
SELECT MI.Id FROM MediaItem MI
|
||||
INNER JOIN LibraryPath LP ON MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId
|
||||
) AS MI
|
||||
)
|
||||
""",
|
||||
new { LibraryId = localLibrary.Id });
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<bool>;
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public class QueueLibraryScanByLibraryIdHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorker,
|
||||
ILogger<QueueLibraryScanByLibraryIdHandler> logger)
|
||||
: IRequestHandler<QueueLibraryScanByLibraryId, bool>
|
||||
{
|
||||
public async Task<bool> Handle(QueueLibraryScanByLibraryId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Library> maybeLibrary = await dbContext.Libraries
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.LibraryId);
|
||||
|
||||
foreach (Library library in maybeLibrary)
|
||||
{
|
||||
if (locker.LockLibrary(library.Id))
|
||||
{
|
||||
logger.LogDebug("Queued library scan for library id {Id}", library.Id);
|
||||
|
||||
switch (library)
|
||||
{
|
||||
case LocalLibrary:
|
||||
await scannerWorker.WriteAsync(new ForceScanLocalLibrary(library.Id), cancellationToken);
|
||||
break;
|
||||
case PlexLibrary:
|
||||
await scannerWorker.WriteAsync(
|
||||
new SynchronizePlexLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizePlexLibraryById(library.Id, false),
|
||||
cancellationToken);
|
||||
break;
|
||||
case JellyfinLibrary:
|
||||
await scannerWorker.WriteAsync(
|
||||
new SynchronizeJellyfinLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizeJellyfinLibraryById(library.Id, false),
|
||||
cancellationToken);
|
||||
break;
|
||||
case EmbyLibrary:
|
||||
await scannerWorker.WriteAsync(
|
||||
new SynchronizeEmbyLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizeEmbyLibraryById(library.Id, false),
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
@@ -27,33 +26,16 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
|
||||
EmptyTrash request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string[] types =
|
||||
{
|
||||
LuceneSearchIndex.MovieType,
|
||||
LuceneSearchIndex.ShowType,
|
||||
LuceneSearchIndex.SeasonType,
|
||||
LuceneSearchIndex.EpisodeType,
|
||||
LuceneSearchIndex.MusicVideoType,
|
||||
LuceneSearchIndex.OtherVideoType,
|
||||
LuceneSearchIndex.SongType,
|
||||
LuceneSearchIndex.ArtistType
|
||||
};
|
||||
SearchResult result = await _searchIndex.Search(_client, "state:FileNotFound", 0, 10_000);
|
||||
var ids = result.Items.Map(i => i.Id).ToList();
|
||||
|
||||
var ids = new List<int>();
|
||||
|
||||
foreach (string type in types)
|
||||
// ElasticSearch remove items may fail, so do that first
|
||||
if (await _searchIndex.RemoveItems(ids))
|
||||
{
|
||||
SearchResult result = await _searchIndex.Search(_client, $"type:{type} AND (state:FileNotFound)", 0, 0);
|
||||
ids.AddRange(result.Items.Map(i => i.Id));
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> deleteResult = await _mediaItemRepository.DeleteItems(ids);
|
||||
if (deleteResult.IsRight)
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
return await _mediaItemRepository.DeleteItems(ids);
|
||||
}
|
||||
|
||||
return deleteResult;
|
||||
return BaseError.New("Failed to empty trash");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,13 +117,22 @@ internal static class Mapper
|
||||
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path,
|
||||
localPath);
|
||||
|
||||
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
|
||||
new(
|
||||
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata)
|
||||
{
|
||||
string poster = GetThumbnail(otherVideoMetadata, None, None);
|
||||
if (string.IsNullOrWhiteSpace(poster))
|
||||
{
|
||||
poster = GetPoster(otherVideoMetadata, None, None);
|
||||
}
|
||||
|
||||
return new OtherVideoCardViewModel(
|
||||
otherVideoMetadata.OtherVideoId,
|
||||
otherVideoMetadata.Title,
|
||||
otherVideoMetadata.OriginalTitle,
|
||||
otherVideoMetadata.SortTitle,
|
||||
poster,
|
||||
otherVideoMetadata.OtherVideo.State);
|
||||
}
|
||||
|
||||
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata)
|
||||
{
|
||||
|
||||
@@ -7,12 +7,13 @@ public record OtherVideoCardViewModel(
|
||||
string Title,
|
||||
string Subtitle,
|
||||
string SortTitle,
|
||||
string Poster,
|
||||
MediaItemState State) : MediaCardViewModel(
|
||||
OtherVideoId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
null,
|
||||
Poster,
|
||||
State)
|
||||
{
|
||||
public int CustomIndex { get; set; }
|
||||
|
||||
@@ -97,6 +97,12 @@ public class GetCollectionCardsHandler :
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Song).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Image).ImageMetadata)
|
||||
.ThenInclude(ovm => ovm.Artwork)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Image).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
|
||||
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
|
||||
|
||||
@@ -33,7 +33,9 @@ public class AddEpisodeToCollectionHandler :
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => ApplyAddTelevisionEpisodeRequest(dbContext, parameters));
|
||||
return await LanguageExtensions.Apply(
|
||||
validation,
|
||||
parameters => ApplyAddTelevisionEpisodeRequest(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyAddTelevisionEpisodeRequest(TvContext dbContext, Parameters parameters)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record AddEpisodeToPlaylist(int PlaylistId, int EpisodeId) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,59 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class AddEpisodeToPlaylistHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<AddEpisodeToPlaylist, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
AddEpisodeToPlaylist request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => ApplyAddEpisodeRequest(dbContext, parameters));
|
||||
}
|
||||
|
||||
private static async Task<Unit> ApplyAddEpisodeRequest(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
var playlistItem = new PlaylistItem
|
||||
{
|
||||
Index = parameters.Playlist.Items.Max(i => i.Index) + 1,
|
||||
CollectionType = ProgramScheduleItemCollectionType.Episode,
|
||||
MediaItemId = parameters.Episode.Id,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
IncludeInProgramGuide = true
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
AddEpisodeToPlaylist request) =>
|
||||
(await PlaylistMustExist(dbContext, request), await ValidateEpisode(dbContext, request))
|
||||
.Apply((collection, episode) => new Parameters(collection, episode));
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
TvContext dbContext,
|
||||
AddEpisodeToPlaylist request) =>
|
||||
dbContext.Playlists
|
||||
.Include(c => c.Items)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playlist does not exist."));
|
||||
|
||||
private static Task<Validation<BaseError, Episode>> ValidateEpisode(
|
||||
TvContext dbContext,
|
||||
AddEpisodeToPlaylist request) =>
|
||||
dbContext.Episodes
|
||||
.SelectOneAsync(m => m.Id, e => e.Id == request.EpisodeId)
|
||||
.Map(o => o.ToValidation<BaseError>("Episode does not exist"));
|
||||
|
||||
private sealed record Parameters(Playlist Playlist, Episode Episode);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record AddItemsToPlaylist(
|
||||
int PlaylistId,
|
||||
List<int> MovieIds,
|
||||
List<int> ShowIds,
|
||||
List<int> SeasonIds,
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds,
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds,
|
||||
List<int> ImageIds) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,120 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public AddItemsToPlaylistHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMovieRepository movieRepository,
|
||||
ITelevisionRepository televisionRepository)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_movieRepository = movieRepository;
|
||||
_televisionRepository = televisionRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(AddItemsToPlaylist request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playlist> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request));
|
||||
}
|
||||
|
||||
private static async Task<Unit> ApplyAddItemsRequest(
|
||||
TvContext dbContext,
|
||||
Playlist playlist,
|
||||
AddItemsToPlaylist request)
|
||||
{
|
||||
var allItems = new Dictionary<ProgramScheduleItemCollectionType, List<int>>
|
||||
{
|
||||
{ ProgramScheduleItemCollectionType.Movie, request.MovieIds },
|
||||
{ ProgramScheduleItemCollectionType.TelevisionShow, request.ShowIds },
|
||||
{ ProgramScheduleItemCollectionType.TelevisionSeason, request.SeasonIds },
|
||||
{ ProgramScheduleItemCollectionType.Episode, request.EpisodeIds },
|
||||
{ ProgramScheduleItemCollectionType.Artist, request.ArtistIds },
|
||||
{ ProgramScheduleItemCollectionType.MusicVideo, request.MusicVideoIds },
|
||||
{ ProgramScheduleItemCollectionType.OtherVideo, request.OtherVideoIds },
|
||||
{ ProgramScheduleItemCollectionType.Song, request.SongIds },
|
||||
{ ProgramScheduleItemCollectionType.Image, request.ImageIds }
|
||||
};
|
||||
|
||||
int index = playlist.Items.Count > 0 ? playlist.Items.Max(i => i.Index) + 1 : 0;
|
||||
|
||||
foreach ((ProgramScheduleItemCollectionType collectionType, List<int> ids) in allItems)
|
||||
{
|
||||
foreach (int id in ids)
|
||||
{
|
||||
var item = new PlaylistItem
|
||||
{
|
||||
Index = index++,
|
||||
CollectionType = collectionType,
|
||||
MediaItemId = id,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
IncludeInProgramGuide = true
|
||||
};
|
||||
|
||||
playlist.Items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Playlist>> Validate(
|
||||
TvContext dbContext,
|
||||
AddItemsToPlaylist request) =>
|
||||
(await PlaylistMustExist(dbContext, request),
|
||||
await ValidateMovies(request),
|
||||
await ValidateShows(request),
|
||||
await ValidateSeasons(request),
|
||||
await ValidateEpisodes(request))
|
||||
.Apply((collection, _, _, _, _) => collection);
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
TvContext dbContext,
|
||||
AddItemsToPlaylist request) =>
|
||||
dbContext.Playlists
|
||||
.Include(c => c.Items)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playlist does not exist."));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToPlaylist request) =>
|
||||
_movieRepository.AllMoviesExist(request.MovieIds)
|
||||
.Map(Optional)
|
||||
.Filter(v => v == true)
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Movie does not exist"));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateShows(AddItemsToPlaylist request) =>
|
||||
_televisionRepository.AllShowsExist(request.ShowIds)
|
||||
.Map(Optional)
|
||||
.Filter(v => v == true)
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateSeasons(AddItemsToPlaylist request) =>
|
||||
_televisionRepository.AllSeasonsExist(request.SeasonIds)
|
||||
.Map(Optional)
|
||||
.Filter(v => v == true)
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Season does not exist"));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToPlaylist request) =>
|
||||
_televisionRepository.AllEpisodesExist(request.EpisodeIds)
|
||||
.Map(Optional)
|
||||
.Filter(v => v == true)
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record AddMovieToPlaylist(int PlaylistId, int MovieId) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,59 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class AddMovieToPlaylistHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<AddMovieToPlaylist, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
AddMovieToPlaylist request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => ApplyAddMovieRequest(dbContext, parameters));
|
||||
}
|
||||
|
||||
private static async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
var playlistItem = new PlaylistItem
|
||||
{
|
||||
Index = parameters.Playlist.Items.Max(i => i.Index) + 1,
|
||||
CollectionType = ProgramScheduleItemCollectionType.Movie,
|
||||
MediaItemId = parameters.Movie.Id,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
IncludeInProgramGuide = true
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
AddMovieToPlaylist request) =>
|
||||
(await PlaylistMustExist(dbContext, request), await ValidateMovie(dbContext, request))
|
||||
.Apply((collection, episode) => new Parameters(collection, episode));
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
TvContext dbContext,
|
||||
AddMovieToPlaylist request) =>
|
||||
dbContext.Playlists
|
||||
.Include(c => c.Items)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playlist does not exist."));
|
||||
|
||||
private static Task<Validation<BaseError, Movie>> ValidateMovie(
|
||||
TvContext dbContext,
|
||||
AddMovieToPlaylist request) =>
|
||||
dbContext.Movies
|
||||
.SelectOneAsync(m => m.Id, e => e.Id == request.MovieId)
|
||||
.Map(o => o.ToValidation<BaseError>("Movie does not exist"));
|
||||
|
||||
private sealed record Parameters(Playlist Playlist, Movie Movie);
|
||||
}
|
||||
@@ -33,7 +33,9 @@ public class AddMusicVideoToCollectionHandler :
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => ApplyAddMusicVideoRequest(dbContext, parameters));
|
||||
return await LanguageExtensions.Apply(
|
||||
validation,
|
||||
parameters => ApplyAddMusicVideoRequest(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyAddMusicVideoRequest(TvContext dbContext, Parameters parameters)
|
||||
|
||||
@@ -33,7 +33,9 @@ public class AddOtherVideoToCollectionHandler :
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => ApplyAddOtherVideoRequest(dbContext, parameters));
|
||||
return await LanguageExtensions.Apply(
|
||||
validation,
|
||||
parameters => ApplyAddOtherVideoRequest(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record AddSeasonToPlaylist(int PlaylistId, int SeasonId) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,59 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class AddSeasonToPlaylistHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<AddSeasonToPlaylist, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
AddSeasonToPlaylist request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => ApplyAddSeasonRequest(dbContext, parameters));
|
||||
}
|
||||
|
||||
private static async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
var playlistItem = new PlaylistItem
|
||||
{
|
||||
Index = parameters.Playlist.Items.Max(i => i.Index) + 1,
|
||||
CollectionType = ProgramScheduleItemCollectionType.TelevisionSeason,
|
||||
MediaItemId = parameters.Season.Id,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
IncludeInProgramGuide = true
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
AddSeasonToPlaylist request) =>
|
||||
(await PlaylistMustExist(dbContext, request), await ValidateSeason(dbContext, request))
|
||||
.Apply((collection, episode) => new Parameters(collection, episode));
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
TvContext dbContext,
|
||||
AddSeasonToPlaylist request) =>
|
||||
dbContext.Playlists
|
||||
.Include(c => c.Items)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playlist does not exist."));
|
||||
|
||||
private static Task<Validation<BaseError, Season>> ValidateSeason(
|
||||
TvContext dbContext,
|
||||
AddSeasonToPlaylist request) =>
|
||||
dbContext.Seasons
|
||||
.SelectOneAsync(m => m.Id, e => e.Id == request.SeasonId)
|
||||
.Map(o => o.ToValidation<BaseError>("Season does not exist"));
|
||||
|
||||
private sealed record Parameters(Playlist Playlist, Season Season);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record AddShowToPlaylist(int PlaylistId, int ShowId) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,59 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class AddShowToPlaylistHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<AddShowToPlaylist, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
AddShowToPlaylist request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => ApplyAddShowRequest(dbContext, parameters));
|
||||
}
|
||||
|
||||
private static async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
var playlistItem = new PlaylistItem
|
||||
{
|
||||
Index = parameters.Playlist.Items.Max(i => i.Index) + 1,
|
||||
CollectionType = ProgramScheduleItemCollectionType.TelevisionShow,
|
||||
MediaItemId = parameters.Show.Id,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
IncludeInProgramGuide = true
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
AddShowToPlaylist request) =>
|
||||
(await PlaylistMustExist(dbContext, request), await ValidateShow(dbContext, request))
|
||||
.Apply((collection, episode) => new Parameters(collection, episode));
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
TvContext dbContext,
|
||||
AddShowToPlaylist request) =>
|
||||
dbContext.Playlists
|
||||
.Include(c => c.Items)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playlist does not exist."));
|
||||
|
||||
private static Task<Validation<BaseError, Show>> ValidateShow(
|
||||
TvContext dbContext,
|
||||
AddShowToPlaylist request) =>
|
||||
dbContext.Shows
|
||||
.SelectOneAsync(m => m.Id, e => e.Id == request.ShowId)
|
||||
.Map(o => o.ToValidation<BaseError>("Show does not exist"));
|
||||
|
||||
private sealed record Parameters(Playlist Playlist, Show Show);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
@@ -11,7 +12,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktList, Either<BaseError, Unit>>
|
||||
public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktList, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
@@ -47,8 +48,11 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
|
||||
private static Validation<BaseError, Parameters> ValidateUrl(AddTraktList request)
|
||||
{
|
||||
const string PATTERN = @"(?:https:\/\/trakt\.tv\/users\/)?([\w\-_]+)\/(?:lists\/)?([\w\-_]+)";
|
||||
Match match = Regex.Match(request.TraktListUrl, PATTERN);
|
||||
// if we get a url, ensure it's for trakt.tv
|
||||
Match match = Uri.IsWellFormedUriString(request.TraktListUrl, UriKind.Absolute)
|
||||
? UriTraktListRegex().Match(request.TraktListUrl)
|
||||
: ShorthandTraktListRegex().Match(request.TraktListUrl);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
string user = match.Groups[1].Value;
|
||||
@@ -63,14 +67,33 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
return await TraktApiClient.GetUserList(parameters.User, parameters.List)
|
||||
.BindT(list => SaveList(dbContext, list))
|
||||
.BindT(list => SaveListItems(dbContext, list))
|
||||
.BindT(list => MatchListItems(dbContext, list))
|
||||
.MapT(_ => Unit.Default);
|
||||
Logger.LogDebug("Searching for trakt list: {User}/{List}", parameters.User, parameters.List);
|
||||
Either<BaseError, TraktList> maybeList = await TraktApiClient.GetUserList(parameters.User, parameters.List);
|
||||
|
||||
// match list items (and update in search index)
|
||||
foreach (TraktList list in maybeList.RightToSeq())
|
||||
{
|
||||
maybeList = await SaveList(dbContext, list);
|
||||
}
|
||||
|
||||
foreach (TraktList list in maybeList.RightToSeq())
|
||||
{
|
||||
maybeList = await SaveListItems(dbContext, list);
|
||||
}
|
||||
|
||||
foreach (TraktList list in maybeList.RightToSeq())
|
||||
{
|
||||
// match list items (and update in search index)
|
||||
maybeList = await MatchListItems(dbContext, list);
|
||||
}
|
||||
|
||||
return maybeList.Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
private sealed record Parameters(string User, string List);
|
||||
|
||||
[GeneratedRegex(@"https:\/\/trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
|
||||
private static partial Regex UriTraktListRegex();
|
||||
|
||||
[GeneratedRegex(@"([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
|
||||
private static partial Regex ShorthandTraktListRegex();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record CreatePlaylist(int PlaylistGroupId, string Name) : IRequest<Either<BaseError, PlaylistViewModel>>;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record CreatePlaylistGroup(string Name) : IRequest<Either<BaseError, PlaylistGroupViewModel>>;
|
||||
@@ -0,0 +1,35 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class CreatePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<CreatePlaylistGroup, Either<BaseError, PlaylistGroupViewModel>>
|
||||
{
|
||||
public async Task<Either<BaseError, PlaylistGroupViewModel>> Handle(
|
||||
CreatePlaylistGroup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, PlaylistGroup> validation = await Validate(request);
|
||||
return await validation.Apply(profile => PersistPlaylistGroup(dbContext, profile));
|
||||
}
|
||||
|
||||
private static async Task<PlaylistGroupViewModel> PersistPlaylistGroup(
|
||||
TvContext dbContext,
|
||||
PlaylistGroup playlistGroup)
|
||||
{
|
||||
await dbContext.PlaylistGroups.AddAsync(playlistGroup);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Mapper.ProjectToViewModel(playlistGroup);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, PlaylistGroup>> Validate(CreatePlaylistGroup request) =>
|
||||
Task.FromResult(ValidateName(request).Map(name => new PlaylistGroup { Name = name, Playlists = [] }));
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreatePlaylistGroup createPlaylistGroup) =>
|
||||
createPlaylistGroup.NotEmpty(x => x.Name)
|
||||
.Bind(_ => createPlaylistGroup.NotLongerThan(50)(x => x.Name));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class CreatePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<CreatePlaylist, Either<BaseError, PlaylistViewModel>>
|
||||
{
|
||||
public async Task<Either<BaseError, PlaylistViewModel>> Handle(
|
||||
CreatePlaylist request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playlist> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(profile => PersistPlaylist(dbContext, profile));
|
||||
}
|
||||
|
||||
private static async Task<PlaylistViewModel> PersistPlaylist(TvContext dbContext, Playlist playlist)
|
||||
{
|
||||
await dbContext.Playlists.AddAsync(playlist);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Mapper.ProjectToViewModel(playlist);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Playlist>> Validate(TvContext dbContext, CreatePlaylist request) =>
|
||||
await ValidatePlaylistName(dbContext, request).MapT(
|
||||
name => new Playlist
|
||||
{
|
||||
PlaylistGroupId = request.PlaylistGroupId,
|
||||
Name = name
|
||||
});
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidatePlaylistName(
|
||||
TvContext dbContext,
|
||||
CreatePlaylist request)
|
||||
{
|
||||
if (request.Name.Length > 50)
|
||||
{
|
||||
return BaseError.New($"Playlist name \"{request.Name}\" is invalid");
|
||||
}
|
||||
|
||||
Option<Playlist> maybeExisting = await dbContext.Playlists
|
||||
.FirstOrDefaultAsync(r => r.PlaylistGroupId == request.PlaylistGroupId && r.Name == request.Name)
|
||||
.Map(Optional);
|
||||
|
||||
return maybeExisting.IsSome
|
||||
? BaseError.New($"A playlist named \"{request.Name}\" already exists in that playlist group")
|
||||
: Success<BaseError, string>(request.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record DeletePlaylist(int PlaylistId) : IRequest<Option<BaseError>>;
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record DeletePlaylistGroup(int PlaylistGroupId) : IRequest<Option<BaseError>>;
|
||||
@@ -0,0 +1,29 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class DeletePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<DeletePlaylistGroup, Option<BaseError>>
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(DeletePlaylistGroup request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<PlaylistGroup> maybePlaylistGroup = await dbContext.PlaylistGroups
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlaylistGroupId);
|
||||
|
||||
foreach (PlaylistGroup playlistGroup in maybePlaylistGroup)
|
||||
{
|
||||
dbContext.PlaylistGroups.Remove(playlistGroup);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return maybePlaylistGroup.Match(
|
||||
_ => Option<BaseError>.None,
|
||||
() => BaseError.New($"PlaylistGroup {request.PlaylistGroupId} does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class DeletePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<DeletePlaylist, Option<BaseError>>
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(DeletePlaylist request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playlist> maybePlaylist = await dbContext.Playlists
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlaylistId);
|
||||
|
||||
foreach (Playlist playlist in maybePlaylist)
|
||||
{
|
||||
dbContext.Playlists.Remove(playlist);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return maybePlaylist.Match(
|
||||
_ => Option<BaseError>.None,
|
||||
() => BaseError.New($"Playlist {request.PlaylistId} does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record PreviewPlaylistPlayout(ReplacePlaylistItems Data) : IRequest<List<PlayoutItemPreviewViewModel>>;
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
|
||||
public class PreviewPlaylistPlayoutHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
IPlayoutBuilder playoutBuilder)
|
||||
: IRequestHandler<PreviewPlaylistPlayout, List<PlayoutItemPreviewViewModel>>
|
||||
{
|
||||
public async Task<List<PlayoutItemPreviewViewModel>> Handle(
|
||||
PreviewPlaylistPlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
Number = "1",
|
||||
Name = "Playlist Preview"
|
||||
},
|
||||
Items = [],
|
||||
ProgramSchedulePlayoutType = ProgramSchedulePlayoutType.Flood,
|
||||
PlayoutHistory = [],
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = [MapToScheduleItem(request)]
|
||||
},
|
||||
ProgramScheduleAlternates = [],
|
||||
FillGroupIndices = []
|
||||
};
|
||||
|
||||
// TODO: make an explicit method to preview, this is ugly
|
||||
playoutBuilder.TrimStart = false;
|
||||
playoutBuilder.DebugPlaylist = playout.ProgramSchedule.Items[0].Playlist;
|
||||
await playoutBuilder.Build(playout, PlayoutBuildMode.Reset, cancellationToken);
|
||||
|
||||
var maxItems = 0;
|
||||
Dictionary<PlaylistItem, List<MediaItem>> map =
|
||||
await mediaCollectionRepository.GetPlaylistItemMap(playout.ProgramSchedule.Items[0].Playlist);
|
||||
foreach (PlaylistItem item in playout.ProgramSchedule.Items[0].Playlist.Items)
|
||||
{
|
||||
if (item.PlayAll)
|
||||
{
|
||||
maxItems += map[item].Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
maxItems += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// limit preview to once through the playlist
|
||||
playout.Items = playout.Items.Take(maxItems).ToList();
|
||||
|
||||
// load playout item details for title
|
||||
foreach (PlayoutItem playoutItem in playout.Items)
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await dbContext.MediaItems
|
||||
.AsNoTracking()
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.Include(mi => (mi as Movie).MediaVersions)
|
||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.Include(mi => (mi as MusicVideo).MediaVersions)
|
||||
.Include(mi => (mi as MusicVideo).Artist)
|
||||
.ThenInclude(mm => mm.ArtistMetadata)
|
||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||
.Include(mi => (mi as Episode).MediaVersions)
|
||||
.Include(mi => (mi as Episode).Season)
|
||||
.ThenInclude(s => s.SeasonMetadata)
|
||||
.Include(mi => (mi as Episode).Season.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||
.Include(mi => (mi as Song).SongMetadata)
|
||||
.Include(mi => (mi as Song).MediaVersions)
|
||||
.Include(mi => (mi as Image).ImageMetadata)
|
||||
.Include(mi => (mi as Image).MediaVersions)
|
||||
.SelectOneAsync(mi => mi.Id, mi => mi.Id == playoutItem.MediaItemId);
|
||||
|
||||
foreach (MediaItem mediaItem in maybeMediaItem)
|
||||
{
|
||||
playoutItem.MediaItem = mediaItem;
|
||||
}
|
||||
}
|
||||
|
||||
return playout.Items.OrderBy(i => i.StartOffset).Map(Scheduling.Mapper.ProjectToViewModel).ToList();
|
||||
}
|
||||
|
||||
private static ProgramScheduleItemFlood MapToScheduleItem(PreviewPlaylistPlayout request) =>
|
||||
new()
|
||||
{
|
||||
CollectionType = ProgramScheduleItemCollectionType.Playlist,
|
||||
Playlist = new Playlist
|
||||
{
|
||||
Items = request.Data.Items.OrderBy(i => i.Index).Map(MapToPlaylistItem).ToList()
|
||||
},
|
||||
PlaylistId = request.Data.PlaylistId,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle
|
||||
};
|
||||
|
||||
private static PlaylistItem MapToPlaylistItem(ReplacePlaylistItem item) =>
|
||||
new()
|
||||
{
|
||||
Index = item.Index,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
SmartCollectionId = item.SmartCollectionId,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
PlayAll = item.PlayAll
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record ReplacePlaylistItem(
|
||||
int Index,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? MediaItemId,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
bool PlayAll,
|
||||
bool IncludeInProgramGuide);
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record ReplacePlaylistItems(int PlaylistId, string Name, List<ReplacePlaylistItem> Items)
|
||||
: IRequest<Either<BaseError, List<PlaylistItemViewModel>>>;
|
||||
@@ -0,0 +1,134 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ReplacePlaylistItems, Either<BaseError, List<PlaylistItemViewModel>>>
|
||||
{
|
||||
public async Task<Either<BaseError, List<PlaylistItemViewModel>>> Handle(
|
||||
ReplacePlaylistItems request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playlist> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(ps => Persist(dbContext, request, ps));
|
||||
}
|
||||
|
||||
private static async Task<List<PlaylistItemViewModel>> Persist(
|
||||
TvContext dbContext,
|
||||
ReplacePlaylistItems request,
|
||||
Playlist playlist)
|
||||
{
|
||||
playlist.Name = request.Name;
|
||||
//playlist.DateUpdated = DateTime.UtcNow;
|
||||
|
||||
dbContext.RemoveRange(playlist.Items);
|
||||
playlist.Items = request.Items.Map(i => BuildItem(playlist, i.Index, i)).ToList();
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return playlist.Items.Map(Mapper.ProjectToViewModel).ToList();
|
||||
}
|
||||
|
||||
private static PlaylistItem BuildItem(Playlist playlist, int index, ReplacePlaylistItem item) =>
|
||||
new()
|
||||
{
|
||||
PlaylistId = playlist.Id,
|
||||
Index = index,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
SmartCollectionId = item.SmartCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
PlayAll = item.PlayAll,
|
||||
IncludeInProgramGuide = item.IncludeInProgramGuide
|
||||
};
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> Validate(TvContext dbContext, ReplacePlaylistItems request) =>
|
||||
PlaylistMustExist(dbContext, request.PlaylistId)
|
||||
.BindT(playlist => CollectionTypesMustBeValid(request, playlist));
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(TvContext dbContext, int playlistId) =>
|
||||
dbContext.Playlists
|
||||
.Include(b => b.Items)
|
||||
.SelectOneAsync(b => b.Id, b => b.Id == playlistId)
|
||||
.Map(o => o.ToValidation<BaseError>("[PlaylistId] does not exist."));
|
||||
|
||||
private static Validation<BaseError, Playlist> CollectionTypesMustBeValid(
|
||||
ReplacePlaylistItems request,
|
||||
Playlist playlist) =>
|
||||
request.Items.Map(item => CollectionTypeMustBeValid(item, playlist)).Sequence().Map(_ => playlist);
|
||||
|
||||
private static Validation<BaseError, Playlist> CollectionTypeMustBeValid(
|
||||
ReplacePlaylistItem item,
|
||||
Playlist playlist)
|
||||
{
|
||||
switch (item.CollectionType)
|
||||
{
|
||||
case ProgramScheduleItemCollectionType.Collection:
|
||||
if (item.CollectionId is null)
|
||||
{
|
||||
return BaseError.New("[Collection] is required for collection type 'Collection'");
|
||||
}
|
||||
|
||||
break;
|
||||
case ProgramScheduleItemCollectionType.TelevisionShow:
|
||||
if (item.MediaItemId is null)
|
||||
{
|
||||
return BaseError.New("[MediaItem] is required for collection type 'TelevisionShow'");
|
||||
}
|
||||
|
||||
break;
|
||||
case ProgramScheduleItemCollectionType.TelevisionSeason:
|
||||
if (item.MediaItemId is null)
|
||||
{
|
||||
return BaseError.New("[MediaItem] is required for collection type 'TelevisionSeason'");
|
||||
}
|
||||
|
||||
break;
|
||||
case ProgramScheduleItemCollectionType.Artist:
|
||||
if (item.MediaItemId is null)
|
||||
{
|
||||
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;
|
||||
case ProgramScheduleItemCollectionType.Movie:
|
||||
case ProgramScheduleItemCollectionType.Episode:
|
||||
case ProgramScheduleItemCollectionType.MusicVideo:
|
||||
case ProgramScheduleItemCollectionType.OtherVideo:
|
||||
case ProgramScheduleItemCollectionType.Song:
|
||||
case ProgramScheduleItemCollectionType.Image:
|
||||
if (item.MediaItemId is null)
|
||||
{
|
||||
return BaseError.New($"[MediaItem] is required for type '{item.CollectionType}'");
|
||||
}
|
||||
|
||||
break;
|
||||
case ProgramScheduleItemCollectionType.FakeCollection:
|
||||
default:
|
||||
return BaseError.New("[CollectionType] is invalid");
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,15 @@ public abstract class TraktCommandBase
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
|
||||
TraktApiClient = traktApiClient;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
protected ITraktApiClient TraktApiClient { get; }
|
||||
|
||||
protected ILogger Logger { get; }
|
||||
|
||||
protected static Task<Validation<BaseError, TraktList>>
|
||||
TraktListMustExist(TvContext dbContext, int traktListId) =>
|
||||
dbContext.TraktLists
|
||||
@@ -43,8 +47,10 @@ public abstract class TraktCommandBase
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == traktListId)
|
||||
.Map(o => o.ToValidation<BaseError>($"TraktList {traktListId} does not exist."));
|
||||
|
||||
protected static async Task<Either<BaseError, TraktList>> SaveList(TvContext dbContext, TraktList list)
|
||||
protected async Task<Either<BaseError, TraktList>> SaveList(TvContext dbContext, TraktList list)
|
||||
{
|
||||
_logger.LogDebug("Saving trakt list to database: {User}/{List}", list.User, list.List);
|
||||
|
||||
Option<TraktList> maybeExisting = await dbContext.TraktLists
|
||||
.Include(l => l.Items)
|
||||
.ThenInclude(i => i.Guids)
|
||||
@@ -72,6 +78,8 @@ public abstract class TraktCommandBase
|
||||
|
||||
protected async Task<Either<BaseError, TraktList>> SaveListItems(TvContext dbContext, TraktList list)
|
||||
{
|
||||
_logger.LogDebug("Saving trakt list items to database: {User}/{List}", list.User, list.List);
|
||||
|
||||
Either<BaseError, List<TraktListItemWithGuids>> maybeItems =
|
||||
await TraktApiClient.GetUserListItems(list.User, list.List);
|
||||
|
||||
@@ -118,6 +126,8 @@ public abstract class TraktCommandBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Matching trakt list items: {User}/{List}", list.User, list.List);
|
||||
|
||||
var ids = new System.Collections.Generic.HashSet<int>();
|
||||
|
||||
foreach (TraktListItem item in list.Items
|
||||
|
||||
@@ -45,4 +45,39 @@ internal static class Mapper
|
||||
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
|
||||
multiCollectionSmartItem.ScheduleAsGroup,
|
||||
multiCollectionSmartItem.PlaybackOrder);
|
||||
|
||||
internal static PlaylistGroupViewModel ProjectToViewModel(PlaylistGroup playlistGroup) =>
|
||||
new(playlistGroup.Id, playlistGroup.Name, playlistGroup.Playlists.Count);
|
||||
|
||||
internal static PlaylistViewModel ProjectToViewModel(Playlist playlist) =>
|
||||
new(playlist.Id, playlist.PlaylistGroupId, playlist.Name);
|
||||
|
||||
internal static PlaylistItemViewModel ProjectToViewModel(PlaylistItem playlistItem) =>
|
||||
new(
|
||||
playlistItem.Id,
|
||||
playlistItem.Index,
|
||||
playlistItem.CollectionType,
|
||||
playlistItem.Collection is not null ? ProjectToViewModel(playlistItem.Collection) : null,
|
||||
playlistItem.MultiCollection is not null
|
||||
? ProjectToViewModel(playlistItem.MultiCollection)
|
||||
: null,
|
||||
playlistItem.SmartCollection is not null
|
||||
? ProjectToViewModel(playlistItem.SmartCollection)
|
||||
: null,
|
||||
playlistItem.MediaItem switch
|
||||
{
|
||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||
Season season => MediaItems.Mapper.ProjectToViewModel(season),
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
Movie movie => MediaItems.Mapper.ProjectToViewModel(movie),
|
||||
Episode episode => MediaItems.Mapper.ProjectToViewModel(episode),
|
||||
MusicVideo musicVideo => MediaItems.Mapper.ProjectToViewModel(musicVideo),
|
||||
OtherVideo otherVideo => MediaItems.Mapper.ProjectToViewModel(otherVideo),
|
||||
Song song => MediaItems.Mapper.ProjectToViewModel(song),
|
||||
Image image => MediaItems.Mapper.ProjectToViewModel(image),
|
||||
_ => null
|
||||
},
|
||||
playlistItem.PlaybackOrder,
|
||||
playlistItem.PlayAll,
|
||||
playlistItem.IncludeInProgramGuide);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record PlaylistGroupViewModel(int Id, string Name, int PlaylistCount);
|
||||
@@ -0,0 +1,16 @@
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record PlaylistItemViewModel(
|
||||
int Id,
|
||||
int Index,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
MediaCollectionViewModel Collection,
|
||||
MultiCollectionViewModel MultiCollection,
|
||||
SmartCollectionViewModel SmartCollection,
|
||||
NamedMediaItemViewModel MediaItem,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
bool PlayAll,
|
||||
bool IncludeInProgramGuide);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record GetAllPlaylistGroups : IRequest<List<PlaylistGroupViewModel>>;
|
||||
@@ -0,0 +1,23 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class GetAllPlaylistGroupsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllPlaylistGroups, List<PlaylistGroupViewModel>>
|
||||
{
|
||||
public async Task<List<PlaylistGroupViewModel>> Handle(
|
||||
GetAllPlaylistGroups request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<PlaylistGroup> playlistGroups = await dbContext.PlaylistGroups
|
||||
.AsNoTracking()
|
||||
.Include(g => g.Playlists)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return playlistGroups.Map(Mapper.ProjectToViewModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record GetPlaylistById(int PlaylistId) : IRequest<Option<PlaylistViewModel>>;
|
||||
@@ -0,0 +1,17 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class GetPlaylistByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetPlaylistById, Option<PlaylistViewModel>>
|
||||
{
|
||||
public async Task<Option<PlaylistViewModel>> Handle(GetPlaylistById request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playlists
|
||||
.SelectOneAsync(b => b.Id, b => b.Id == request.PlaylistId)
|
||||
.MapT(Mapper.ProjectToViewModel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record GetPlaylistItems(int PlaylistId) : IRequest<List<PlaylistItemViewModel>>;
|
||||
@@ -0,0 +1,70 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class GetPlaylistItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetPlaylistItems, List<PlaylistItemViewModel>>
|
||||
{
|
||||
public async Task<List<PlaylistItemViewModel>> Handle(GetPlaylistItems request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<PlaylistItem> allItems = await dbContext.PlaylistItems
|
||||
.AsNoTracking()
|
||||
.Filter(i => i.PlaylistId == request.PlaylistId)
|
||||
.Include(i => i.Collection)
|
||||
.Include(i => i.MultiCollection)
|
||||
.Include(i => i.SmartCollection)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Season).Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Show).ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Artist).ArtistMetadata)
|
||||
.ThenInclude(am => am.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Image).ImageMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (allItems.All(bi => bi.IncludeInProgramGuide == false))
|
||||
{
|
||||
foreach (PlaylistItem bi in allItems)
|
||||
{
|
||||
bi.IncludeInProgramGuide = true;
|
||||
}
|
||||
}
|
||||
|
||||
return allItems.Map(Mapper.ProjectToViewModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record GetPlaylistsByPlaylistGroupId(int PlaylistGroupId) : IRequest<List<PlaylistViewModel>>;
|
||||
@@ -0,0 +1,21 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class GetPlaylistsByPlaylistGroupIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetPlaylistsByPlaylistGroupId, List<PlaylistViewModel>>
|
||||
{
|
||||
public async Task<List<PlaylistViewModel>> Handle(
|
||||
GetPlaylistsByPlaylistGroupId request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
return await dbContext.Playlists
|
||||
.AsNoTracking()
|
||||
.Filter(p => p.PlaylistGroupId == request.PlaylistGroupId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(items => items.Map(Mapper.ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,41 @@ internal static class Mapper
|
||||
internal static NamedMediaItemViewModel ProjectToViewModel(Artist artist) =>
|
||||
new(artist.Id, artist.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => "???"));
|
||||
|
||||
internal static NamedMediaItemViewModel ProjectToViewModel(Movie movie) =>
|
||||
new(movie.Id, MovieTitle(movie));
|
||||
|
||||
internal static NamedMediaItemViewModel ProjectToViewModel(Episode episode) =>
|
||||
new(episode.Id, EpisodeTitle(episode));
|
||||
|
||||
internal static NamedMediaItemViewModel ProjectToViewModel(MusicVideo musicVideo) =>
|
||||
new(musicVideo.Id, MusicVideoTitle(musicVideo));
|
||||
|
||||
internal static NamedMediaItemViewModel ProjectToViewModel(OtherVideo otherVideo) =>
|
||||
new(otherVideo.Id, otherVideo.OtherVideoMetadata.HeadOrNone().Match(ov => ov.Title, () => "???"));
|
||||
|
||||
internal static NamedMediaItemViewModel ProjectToViewModel(Song song) =>
|
||||
new(song.Id, SongTitle(song));
|
||||
|
||||
internal static NamedMediaItemViewModel ProjectToViewModel(Image image) =>
|
||||
new(image.Id, image.ImageMetadata.HeadOrNone().Match(i => i.Title, () => "???"));
|
||||
|
||||
private static string MovieTitle(Movie movie)
|
||||
{
|
||||
var title = "???";
|
||||
var year = "???";
|
||||
|
||||
foreach (MovieMetadata movieMetadata in movie.MovieMetadata.HeadOrNone())
|
||||
{
|
||||
title = movieMetadata.Title;
|
||||
foreach (int y in Optional(movieMetadata.Year))
|
||||
{
|
||||
year = y.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
return $"{title} ({year})";
|
||||
}
|
||||
|
||||
private static string ShowTitle(Season season)
|
||||
{
|
||||
var title = "???";
|
||||
@@ -33,4 +68,40 @@ internal static class Mapper
|
||||
|
||||
private static string SeasonDescription(Season season) =>
|
||||
season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}";
|
||||
|
||||
private static string EpisodeTitle(Episode e)
|
||||
{
|
||||
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
|
||||
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
|
||||
var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList();
|
||||
var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList();
|
||||
if (episodeNumbers.Count == 0 || episodeTitles.Count == 0)
|
||||
{
|
||||
return "[unknown episode]";
|
||||
}
|
||||
|
||||
var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}";
|
||||
var titlesString = $"{string.Join('/', episodeTitles)}";
|
||||
|
||||
return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}";
|
||||
}
|
||||
|
||||
private static string MusicVideoTitle(MusicVideo mv)
|
||||
{
|
||||
string artistName = mv.Artist.ArtistMetadata.HeadOrNone()
|
||||
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
|
||||
return mv.MusicVideoMetadata.HeadOrNone()
|
||||
.Map(mvm => $"{artistName}{mvm.Title}")
|
||||
.IfNone("[unknown music video]");
|
||||
}
|
||||
|
||||
private static string SongTitle(Song s)
|
||||
{
|
||||
string songArtist = s.SongMetadata.HeadOrNone()
|
||||
.Map(sm => $"{string.Join(", ", sm.Artists)} - ")
|
||||
.IfNone(string.Empty);
|
||||
return s.SongMetadata.HeadOrNone()
|
||||
.Map(sm => $"{songArtist}{sm.Title ?? string.Empty}")
|
||||
.IfNone("[unknown song]");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems;
|
||||
|
||||
public class GetAllLanguageCodesHandler : IRequestHandler<GetAllLanguageCodes, List<LanguageCodeViewModel>>
|
||||
public class GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository)
|
||||
: IRequestHandler<GetAllLanguageCodes, List<LanguageCodeViewModel>>
|
||||
{
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
|
||||
public GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository) =>
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
|
||||
public async Task<List<LanguageCodeViewModel>> Handle(
|
||||
GetAllLanguageCodes request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<CultureInfo> cultures = await _mediaItemRepository.GetAllLanguageCodeCultures();
|
||||
return cultures.Map(c => new LanguageCodeViewModel(c.ThreeLetterISOLanguageName, c.EnglishName)).ToList();
|
||||
List<LanguageCodeAndName> languageCodes = await mediaItemRepository.GetAllLanguageCodesAndNames();
|
||||
return languageCodes.Map(c => new LanguageCodeViewModel(c.Code, c.Name)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace ErsatzTV.Application.Playouts;
|
||||
public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IBlockPlayoutBuilder _blockPlayoutBuilder;
|
||||
private readonly IBlockPlayoutFillerBuilder _blockPlayoutFillerBuilder;
|
||||
private readonly IYamlPlayoutBuilder _yamlPlayoutBuilder;
|
||||
private readonly IClient _client;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
@@ -31,6 +33,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IPlayoutBuilder playoutBuilder,
|
||||
IBlockPlayoutBuilder blockPlayoutBuilder,
|
||||
IBlockPlayoutFillerBuilder blockPlayoutFillerBuilder,
|
||||
IYamlPlayoutBuilder yamlPlayoutBuilder,
|
||||
IExternalJsonPlayoutBuilder externalJsonPlayoutBuilder,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IEntityLocker entityLocker,
|
||||
@@ -40,6 +44,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_playoutBuilder = playoutBuilder;
|
||||
_blockPlayoutBuilder = blockPlayoutBuilder;
|
||||
_blockPlayoutFillerBuilder = blockPlayoutFillerBuilder;
|
||||
_yamlPlayoutBuilder = yamlPlayoutBuilder;
|
||||
_externalJsonPlayoutBuilder = externalJsonPlayoutBuilder;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_entityLocker = entityLocker;
|
||||
@@ -63,12 +69,16 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
{
|
||||
try
|
||||
{
|
||||
_entityLocker.LockPlayout(playout.Id);
|
||||
await _entityLocker.LockPlayout(playout.Id);
|
||||
|
||||
switch (playout.ProgramSchedulePlayoutType)
|
||||
{
|
||||
case ProgramSchedulePlayoutType.Block:
|
||||
await _blockPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
||||
await _blockPlayoutFillerBuilder.Build(playout, request.Mode, cancellationToken);
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.Yaml:
|
||||
await _yamlPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.ExternalJson:
|
||||
await _externalJsonPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
||||
@@ -125,7 +135,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockPlayout(playout.Id);
|
||||
await _entityLocker.UnlockPlayout(playout.Id);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
@@ -154,6 +164,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
BuildPlayout buildPlayout) =>
|
||||
dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Deco)
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.PlayoutHistory)
|
||||
.Include(p => p.Templates)
|
||||
@@ -161,6 +172,10 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
.ThenInclude(t => t.Items)
|
||||
.ThenInclude(i => i.Block)
|
||||
.ThenInclude(b => b.Items)
|
||||
.Include(p => p.Templates)
|
||||
.ThenInclude(t => t.DecoTemplate)
|
||||
.ThenInclude(t => t.Items)
|
||||
.ThenInclude(i => i.Deco)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
|
||||
@@ -37,6 +37,10 @@ public class CreateFloodPlayoutHandler : IRequestHandler<CreateFloodPlayout, Eit
|
||||
await dbContext.Playouts.AddAsync(playout);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
|
||||
if (playout.Channel.ProgressMode is ChannelProgressMode.OnDemand)
|
||||
{
|
||||
await _channel.WriteAsync(new TimeShiftOnDemandPlayout(playout.Channel.Number, DateTimeOffset.Now, false));
|
||||
}
|
||||
await _channel.WriteAsync(new RefreshChannelList());
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@ public record CreateFloodPlayout(int ChannelId, int ProgramScheduleId)
|
||||
public record CreateBlockPlayout(int ChannelId)
|
||||
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Block);
|
||||
|
||||
public record CreateYamlPlayout(int ChannelId, string TemplateFile)
|
||||
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Yaml);
|
||||
|
||||
public record CreateExternalJsonPlayout(int ChannelId, string ExternalJsonFile)
|
||||
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.ExternalJson);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class CreateYamlPlayoutHandler
|
||||
: IRequestHandler<CreateYamlPlayout, Either<BaseError, CreatePlayoutResponse>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public CreateYamlPlayoutHandler(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_channel = channel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
|
||||
CreateYamlPlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
|
||||
}
|
||||
|
||||
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
|
||||
{
|
||||
await dbContext.Playouts.AddAsync(playout);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
|
||||
await _channel.WriteAsync(new RefreshChannelList());
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Playout>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateYamlPlayout request) =>
|
||||
(await ValidateChannel(dbContext, request), ValidateYamlFile(request), ValidatePlayoutType(request))
|
||||
.Apply(
|
||||
(channel, externalJsonFile, playoutType) => new Playout
|
||||
{
|
||||
ChannelId = channel.Id,
|
||||
TemplateFile = externalJsonFile,
|
||||
ProgramSchedulePlayoutType = playoutType,
|
||||
Seed = new Random().Next()
|
||||
});
|
||||
|
||||
private static Task<Validation<BaseError, Channel>> ValidateChannel(
|
||||
TvContext dbContext,
|
||||
CreateYamlPlayout createYamlPlayout) =>
|
||||
dbContext.Channels
|
||||
.Include(c => c.Playouts)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == createYamlPlayout.ChannelId)
|
||||
.Map(o => o.ToValidation<BaseError>("Channel does not exist"))
|
||||
.BindT(ChannelMustNotHavePlayouts);
|
||||
|
||||
private static Validation<BaseError, Channel> ChannelMustNotHavePlayouts(Channel channel) =>
|
||||
Optional(channel.Playouts.Count)
|
||||
.Filter(count => count == 0)
|
||||
.Map(_ => channel)
|
||||
.ToValidation<BaseError>("Channel already has one playout");
|
||||
|
||||
private Validation<BaseError, string> ValidateYamlFile(CreateYamlPlayout request)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(request.TemplateFile))
|
||||
{
|
||||
return BaseError.New("YAML file does not exist!");
|
||||
}
|
||||
|
||||
return request.TemplateFile;
|
||||
}
|
||||
|
||||
private static Validation<BaseError, ProgramSchedulePlayoutType> ValidatePlayoutType(
|
||||
CreateYamlPlayout createYamlPlayout) =>
|
||||
Optional(createYamlPlayout.ProgramSchedulePlayoutType)
|
||||
.Filter(playoutType => playoutType == ProgramSchedulePlayoutType.Yaml)
|
||||
.ToValidation<BaseError>("[ProgramSchedulePlayoutType] must be YAML");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ResetAllPlayouts : IRequest;
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class ResetAllPlayoutsHandler(
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ResetAllPlayouts>
|
||||
{
|
||||
public async Task Handle(ResetAllPlayouts request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
foreach (Playout playout in await dbContext.Playouts.ToListAsync(cancellationToken))
|
||||
{
|
||||
switch (playout.ProgramSchedulePlayoutType)
|
||||
{
|
||||
case ProgramSchedulePlayoutType.Flood:
|
||||
case ProgramSchedulePlayoutType.Block:
|
||||
case ProgramSchedulePlayoutType.Yaml:
|
||||
if (!locker.IsPlayoutLocked(playout.Id))
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
|
||||
}
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.ExternalJson:
|
||||
case ProgramSchedulePlayoutType.None:
|
||||
default:
|
||||
// external json cannot be reset
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record TimeShiftOnDemandPlayout(string ChannelNumber, DateTimeOffset Now, bool Force)
|
||||
: IRequest<Option<BaseError>>, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,41 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class TimeShiftOnDemandPlayoutHandler(
|
||||
IPlayoutTimeShifter playoutTimeShifter,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<TimeShiftOnDemandPlayout, Option<BaseError>>
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(TimeShiftOnDemandPlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.Anchor)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.SelectOneAsync(p => p.Channel.Number, p => p.Channel.Number == request.ChannelNumber);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
playoutTimeShifter.TimeShift(playout, request.Now, request.Force);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,11 @@ public class
|
||||
playout.ProgramSchedulePlayoutType,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.ProgressMode,
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.TemplateFile,
|
||||
playout.ExternalJsonFile,
|
||||
Optional(playout.DailyRebuildTime));
|
||||
playout.DailyRebuildTime);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user