Compare commits
133
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 |
@@ -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:
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
name: Qodana
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
|
||||
fetch-depth: 0 # a full history is required for pull request analysis
|
||||
- name: 'Qodana Scan'
|
||||
uses: JetBrains/qodana-action@v2024.1
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
@@ -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: |
|
||||
|
||||
+171
-1
@@ -4,6 +4,175 @@ 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
|
||||
@@ -2050,7 +2219,8 @@ 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.7-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
|
||||
|
||||
@@ -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;
|
||||
@@ -182,6 +183,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
switch (playout.ProgramSchedulePlayoutType)
|
||||
{
|
||||
case ProgramSchedulePlayoutType.Flood:
|
||||
case ProgramSchedulePlayoutType.Yaml:
|
||||
var floodSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
@@ -285,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++;
|
||||
}
|
||||
@@ -513,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,
|
||||
@@ -568,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,
|
||||
@@ -628,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,
|
||||
@@ -685,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,
|
||||
@@ -737,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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
@@ -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.10.48">
|
||||
<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,6 +8,7 @@ public record CreateFFmpegProfile(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
|
||||
@@ -9,6 +9,7 @@ public record UpdateFFmpegProfile(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
|
||||
@@ -36,6 +36,7 @@ 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;
|
||||
|
||||
@@ -9,6 +9,7 @@ public record FFmpegProfileViewModel(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
|
||||
@@ -11,6 +11,7 @@ internal static class Mapper
|
||||
profile.Name,
|
||||
profile.ThreadCount,
|
||||
profile.HardwareAcceleration,
|
||||
profile.VaapiDisplay ?? "drm",
|
||||
profile.VaapiDriver,
|
||||
profile.VaapiDevice,
|
||||
profile.QsvExtraHardwareFrames,
|
||||
@@ -46,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));
|
||||
|
||||
@@ -48,7 +48,7 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
|
||||
{ ProgramScheduleItemCollectionType.Image, request.ImageIds }
|
||||
};
|
||||
|
||||
int index = playlist.Items.Max(i => i.Index) + 1;
|
||||
int index = playlist.Items.Count > 0 ? playlist.Items.Max(i => i.Index) + 1 : 0;
|
||||
|
||||
foreach ((ProgramScheduleItemCollectionType collectionType, List<int> ids) in allItems)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record UpdateOnDemandCheckpoint(string ChannelNumber, DateTimeOffset Checkpoint)
|
||||
: IRequest;
|
||||
@@ -0,0 +1,62 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class UpdateOnDemandCheckpointHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<UpdateOnDemandCheckpointHandler> logger)
|
||||
: IRequestHandler<UpdateOnDemandCheckpoint>
|
||||
{
|
||||
public async Task Handle(UpdateOnDemandCheckpoint request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
.SelectOneAsync(p => p.Channel.Number, p => p.Channel.Number == request.ChannelNumber);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
if (playout.Channel.ProgressMode is not ChannelProgressMode.OnDemand)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int timeout = await (await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout))
|
||||
.IfNoneAsync(60);
|
||||
|
||||
// don't move checkpoint back in time
|
||||
DateTimeOffset newCheckpoint = request.Checkpoint - TimeSpan.FromSeconds(timeout);
|
||||
if (newCheckpoint > playout.OnDemandCheckpoint)
|
||||
{
|
||||
playout.OnDemandCheckpoint = newCheckpoint;
|
||||
}
|
||||
|
||||
// don't checkpoint before the first item
|
||||
// this could happen if you watch a new playout for less time than the segmenter timeout
|
||||
if (playout.Items.Count > 0)
|
||||
{
|
||||
DateTimeOffset minStart = playout.Items.Min(p => p.StartOffset);
|
||||
if (playout.OnDemandCheckpoint < minStart)
|
||||
{
|
||||
playout.OnDemandCheckpoint = minStart;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Updating on demand checkpoint for channel {Number} - {Name} to {Checkpoint}",
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name,
|
||||
playout.OnDemandCheckpoint);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,11 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
||||
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(TvContext dbContext, UpdatePlayout request) =>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class
|
||||
UpdateTemplatePlayoutHandler : IRequestHandler<UpdateYamlPlayout,
|
||||
Either<BaseError, PlayoutNameViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateTemplatePlayoutHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
|
||||
UpdateYamlPlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
}
|
||||
|
||||
private async Task<PlayoutNameViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
UpdateYamlPlayout request,
|
||||
Playout playout)
|
||||
{
|
||||
playout.TemplateFile = request.TemplateFile;
|
||||
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number));
|
||||
}
|
||||
|
||||
return new PlayoutNameViewModel(
|
||||
playout.Id,
|
||||
playout.ProgramSchedulePlayoutType,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.ProgressMode,
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.TemplateFile,
|
||||
playout.ExternalJsonFile,
|
||||
playout.DailyRebuildTime);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateYamlPlayout request) =>
|
||||
PlayoutMustExist(dbContext, request);
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> PlayoutMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateYamlPlayout updatePlayout) =>
|
||||
dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updatePlayout.PlayoutId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record UpdateYamlPlayout(int PlayoutId, string TemplateFile)
|
||||
: IRequest<Either<BaseError, PlayoutNameViewModel>>;
|
||||
@@ -10,7 +10,7 @@ internal static class Mapper
|
||||
GetDisplayTitle(playoutItem),
|
||||
playoutItem.StartOffset,
|
||||
playoutItem.FinishOffset,
|
||||
GetDisplayDuration(playoutItem.FinishOffset - playoutItem.StartOffset));
|
||||
playoutItem.GetDisplayDuration());
|
||||
|
||||
internal static PlayoutAlternateScheduleViewModel ProjectToViewModel(
|
||||
ProgramScheduleAlternate programScheduleAlternate) =>
|
||||
@@ -81,10 +81,4 @@ internal static class Mapper
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string GetDisplayDuration(TimeSpan duration) =>
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
|
||||
duration);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ public record PlayoutNameViewModel(
|
||||
ProgramSchedulePlayoutType PlayoutType,
|
||||
string ChannelName,
|
||||
string ChannelNumber,
|
||||
ChannelProgressMode ProgressMode,
|
||||
string ScheduleName,
|
||||
string TemplateFile,
|
||||
string ExternalJsonFile,
|
||||
Option<TimeSpan> DailyRebuildTime);
|
||||
TimeSpan? DbDailyRebuildTime)
|
||||
{
|
||||
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ public class GetAllPlayoutsHandler : IRequestHandler<GetAllPlayouts, List<Playou
|
||||
p.ProgramSchedulePlayoutType,
|
||||
p.Channel.Name,
|
||||
p.Channel.Number,
|
||||
p.Channel.ProgressMode,
|
||||
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
|
||||
p.TemplateFile,
|
||||
p.ExternalJsonFile,
|
||||
Optional(p.DailyRebuildTime)))
|
||||
p.DailyRebuildTime))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetPlayoutById(int PlayoutId) : IRequest<Option<PlayoutNameViewModel>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetPlayoutById, Option<PlayoutNameViewModel>>
|
||||
{
|
||||
public async Task<Option<PlayoutNameViewModel>> Handle(
|
||||
GetPlayoutById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
|
||||
.MapT(
|
||||
p => new PlayoutNameViewModel(
|
||||
p.Id,
|
||||
p.ProgramSchedulePlayoutType,
|
||||
p.Channel.Name,
|
||||
p.Channel.Number,
|
||||
p.Channel.ProgressMode,
|
||||
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
|
||||
p.TemplateFile,
|
||||
p.ExternalJsonFile,
|
||||
p.DailyRebuildTime));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetPlayoutIdByChannelNumber(string ChannelNumber) : IRequest<Option<int>>;
|
||||
@@ -0,0 +1,18 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetPlayoutIdByChannelNumberHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetPlayoutIdByChannelNumber, Option<int>>
|
||||
{
|
||||
public async Task<Option<int>> Handle(GetPlayoutIdByChannelNumber request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Filter(p => p.Channel.Number == request.ChannelNumber)
|
||||
.Map(p => p.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.HeadOrNone());
|
||||
}
|
||||
}
|
||||
@@ -84,10 +84,13 @@ public class
|
||||
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
|
||||
var toUpdate = libraries
|
||||
.Filter(l => toAdd.All(a => a.Key != l.Key) && toRemove.All(r => r.Key != l.Key)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.PlexMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove);
|
||||
toRemove,
|
||||
toUpdate);
|
||||
if (ids.Count != 0)
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
@@ -10,7 +10,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
|
||||
public class SynchronizePlexMediaSourcesHandler : PlexBaseConnectionHandler, IRequestHandler<SynchronizePlexMediaSources,
|
||||
Either<BaseError, List<PlexMediaSource>>>
|
||||
{
|
||||
private const string LocalhostUri = "http://localhost:32400";
|
||||
@@ -31,6 +31,7 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> channel,
|
||||
IEntityLocker entityLocker,
|
||||
ILogger<SynchronizePlexMediaSourcesHandler> logger)
|
||||
: base(plexServerApiClient, mediaSourceRepository, logger)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexTvApiClient = plexTvApiClient;
|
||||
@@ -102,63 +103,35 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle
|
||||
var toRemove = existing.Connections
|
||||
.Filter(connection => server.Connections.All(c => c.Uri != connection.Uri)).ToList();
|
||||
await _mediaSourceRepository.Update(existing, toAdd, toRemove);
|
||||
await FindConnectionToActivate(existing);
|
||||
Option<PlexServerAuthToken> maybeToken = await _plexSecretStore.GetServerAuthToken(server.ClientIdentifier);
|
||||
if (maybeToken.IsNone)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to activate Plex connection for server {Server} without auth token",
|
||||
server.ServerName);
|
||||
}
|
||||
|
||||
foreach (PlexServerAuthToken token in maybeToken)
|
||||
{
|
||||
await FindConnectionToActivate(existing, token);
|
||||
}
|
||||
}
|
||||
|
||||
if (maybeExisting.IsNone)
|
||||
{
|
||||
await _mediaSourceRepository.Add(server);
|
||||
await FindConnectionToActivate(server);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FindConnectionToActivate(PlexMediaSource server)
|
||||
{
|
||||
var prioritized = server.Connections
|
||||
.OrderByDescending(pc => pc.Uri == LocalhostUri)
|
||||
.ThenByDescending(pc => pc.IsActive)
|
||||
.ToList();
|
||||
|
||||
foreach (PlexConnection connection in server.Connections)
|
||||
{
|
||||
connection.IsActive = false;
|
||||
}
|
||||
|
||||
Option<PlexServerAuthToken> maybeToken = await _plexSecretStore.GetServerAuthToken(server.ClientIdentifier);
|
||||
foreach (PlexServerAuthToken token in maybeToken)
|
||||
{
|
||||
foreach (PlexConnection connection in prioritized)
|
||||
Option<PlexServerAuthToken> maybeToken = await _plexSecretStore.GetServerAuthToken(server.ClientIdentifier);
|
||||
if (maybeToken.IsNone)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Attempting to locate to Plex at {Uri}", connection.Uri);
|
||||
if (await _plexServerApiClient.Ping(connection, token))
|
||||
{
|
||||
_logger.LogInformation("Located Plex at {Uri}", connection.Uri);
|
||||
connection.IsActive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
_logger.LogError(
|
||||
"Unable to activate Plex connection for server {Server} without auth token",
|
||||
server.ServerName);
|
||||
}
|
||||
|
||||
foreach (PlexServerAuthToken token in maybeToken)
|
||||
{
|
||||
await FindConnectionToActivate(server, token);
|
||||
}
|
||||
}
|
||||
|
||||
if (maybeToken.IsNone)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to activate Plex connection for server {Server} without auth token",
|
||||
server.ServerName);
|
||||
}
|
||||
|
||||
if (server.Connections.All(c => !c.IsActive))
|
||||
{
|
||||
_logger.LogError("Unable to locate Plex");
|
||||
server.Connections.Head().IsActive = true;
|
||||
}
|
||||
|
||||
await _mediaSourceRepository.Update(server, new List<PlexConnection>(), new List<PlexConnection>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,15 @@ public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinF
|
||||
public async Task<Either<BaseError, bool>>
|
||||
Handle(TryCompletePlexPinFlow request, CancellationToken cancellationToken)
|
||||
{
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
|
||||
CancellationToken token = cts.Token;
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
|
||||
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
|
||||
CancellationToken token = linkedTokenSource.Token;
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin);
|
||||
if (result)
|
||||
{
|
||||
await _channel.WriteAsync(new SynchronizePlexMediaSources(), cancellationToken);
|
||||
await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public abstract class PlexBaseConnectionHandler(
|
||||
IPlexServerApiClient plexServerApiClient,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ILogger logger)
|
||||
{
|
||||
protected async Task<Option<PlexConnection>> FindConnectionToActivate(PlexMediaSource server, PlexServerAuthToken token)
|
||||
{
|
||||
Option<PlexConnection> result = Option<PlexConnection>.None;
|
||||
|
||||
foreach (PlexConnection connection in server.Connections)
|
||||
{
|
||||
connection.IsActive = false;
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
ConcurrentDictionary<PlexConnection, TimeSpan> successfulTimes = new();
|
||||
var tasks = server.Connections
|
||||
.Map(connection => PingPlexConnection(connection, token, successfulTimes, cts.Token))
|
||||
.ToList();
|
||||
|
||||
while (tasks.Count > 0)
|
||||
{
|
||||
Task completed = await Task.WhenAny(tasks);
|
||||
if (completed.IsCompletedSuccessfully)
|
||||
{
|
||||
if (!successfulTimes.IsEmpty)
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
tasks.Remove(completed);
|
||||
}
|
||||
|
||||
Option<PlexConnection> maybeBest = successfulTimes.OrderByDescending(kv => kv.Value).Select(kvp => kvp.Key).HeadOrNone();
|
||||
foreach (PlexConnection connection in maybeBest)
|
||||
{
|
||||
connection.IsActive = true;
|
||||
}
|
||||
|
||||
if (server.Connections.All(c => !c.IsActive))
|
||||
{
|
||||
logger.LogError("Unable to locate Plex");
|
||||
server.Connections.Head().IsActive = true;
|
||||
}
|
||||
|
||||
await mediaSourceRepository.Update(server, [], []);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task PingPlexConnection(PlexConnection connection, PlexServerAuthToken token, ConcurrentDictionary<PlexConnection, TimeSpan> successfulTimes, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogDebug("Attempting to locate to Plex at {Uri}", connection.Uri);
|
||||
var sw = new System.Diagnostics.Stopwatch();
|
||||
sw.Start();
|
||||
bool pingResult = await plexServerApiClient.Ping(connection, token, cancellationToken);
|
||||
sw.Stop();
|
||||
if (pingResult)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Located Plex at {Uri} in {Milliseconds} ms",
|
||||
connection.Uri,
|
||||
sw.ElapsedMilliseconds);
|
||||
successfulTimes.TryAdd(connection, sw.Elapsed);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Unable to locate Plex at {Uri} after {Milliseconds} ms",
|
||||
connection.Uri,
|
||||
sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,24 +4,32 @@ using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class GetPlexConnectionParametersHandler : IRequestHandler<GetPlexConnectionParameters,
|
||||
public class GetPlexConnectionParametersHandler : PlexBaseConnectionHandler, IRequestHandler<GetPlexConnectionParameters,
|
||||
Either<BaseError, PlexConnectionParametersViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly IPlexSecretStore _plexSecretStore;
|
||||
private readonly ILogger<GetPlexConnectionParametersHandler> _logger;
|
||||
|
||||
public GetPlexConnectionParametersHandler(
|
||||
IMemoryCache memoryCache,
|
||||
IPlexServerApiClient plexServerApiClient,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexSecretStore plexSecretStore)
|
||||
IPlexSecretStore plexSecretStore,
|
||||
ILogger<GetPlexConnectionParametersHandler> logger)
|
||||
: base(plexServerApiClient, mediaSourceRepository, logger)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexSecretStore = plexSecretStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PlexConnectionParametersViewModel>> Handle(
|
||||
@@ -33,55 +41,43 @@ public class GetPlexConnectionParametersHandler : IRequestHandler<GetPlexConnect
|
||||
return parameters;
|
||||
}
|
||||
|
||||
Either<BaseError, PlexConnectionParametersViewModel> maybeParameters =
|
||||
await Validate(request)
|
||||
.MapT(
|
||||
cp => new PlexConnectionParametersViewModel(
|
||||
new Uri(cp.ActiveConnection.Uri),
|
||||
cp.PlexServerAuthToken.AuthToken))
|
||||
.Map(v => v.ToEither<PlexConnectionParametersViewModel>());
|
||||
|
||||
return maybeParameters.Match(
|
||||
p =>
|
||||
Option<PlexMediaSource> maybeMediaSource = await _mediaSourceRepository.GetPlex(request.PlexMediaSourceId);
|
||||
foreach (PlexMediaSource mediaSource in maybeMediaSource)
|
||||
{
|
||||
Option<PlexServerAuthToken> maybeToken =
|
||||
await _plexSecretStore.GetServerAuthToken(mediaSource.ClientIdentifier);
|
||||
foreach (PlexServerAuthToken token in maybeToken)
|
||||
{
|
||||
_memoryCache.Set(request, p, TimeSpan.FromHours(1));
|
||||
return maybeParameters;
|
||||
},
|
||||
error => error);
|
||||
}
|
||||
// try to keep the same connection
|
||||
Option<PlexConnection> maybeActiveConnection = mediaSource.Connections.Filter(c => c.IsActive).HeadOrNone();
|
||||
foreach (PlexConnection activeConnection in maybeActiveConnection)
|
||||
{
|
||||
if (await _plexServerApiClient.Ping(activeConnection, token, cancellationToken))
|
||||
{
|
||||
_logger.LogDebug("Plex connection is still active at {Uri}", activeConnection.Uri);
|
||||
var p = new PlexConnectionParametersViewModel(new Uri(activeConnection.Uri), token.AuthToken);
|
||||
_memoryCache.Set(request, p, TimeSpan.FromSeconds(30));
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(GetPlexConnectionParameters request) =>
|
||||
PlexMediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveToken);
|
||||
_logger.LogInformation("Plex connection is no longer active, searching for a new connection");
|
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
|
||||
GetPlexConnectionParameters request) =>
|
||||
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId)
|
||||
.Map(
|
||||
v => v.ToValidation<BaseError>(
|
||||
$"Plex media source {request.PlexMediaSourceId} does not exist."));
|
||||
// check all connections for a working one
|
||||
Option<PlexConnection> maybeConnection = await FindConnectionToActivate(mediaSource, token);
|
||||
foreach (PlexConnection connection in maybeConnection)
|
||||
{
|
||||
var p = new PlexConnectionParametersViewModel(new Uri(connection.Uri), token.AuthToken);
|
||||
_memoryCache.Set(request, p, TimeSpan.FromMinutes(30));
|
||||
return p;
|
||||
}
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
PlexMediaSource plexMediaSource)
|
||||
{
|
||||
Option<PlexConnection> maybeConnection =
|
||||
plexMediaSource.Connections.SingleOrDefault(c => c.IsActive);
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection))
|
||||
.ToValidation<BaseError>("Plex media source requires an active connection");
|
||||
}
|
||||
return BaseError.New($"Plex media source {request.PlexMediaSourceId} requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveToken(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
Option<PlexServerAuthToken> maybeToken = await
|
||||
_plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier);
|
||||
return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token })
|
||||
.ToValidation<BaseError>("Plex media source requires a token");
|
||||
}
|
||||
return BaseError.New($"Plex media source {request.PlexMediaSourceId} requires a token");
|
||||
}
|
||||
|
||||
private sealed record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
|
||||
{
|
||||
public PlexServerAuthToken PlexServerAuthToken { get; set; }
|
||||
return BaseError.New($"Plex media source {request.PlexMediaSourceId} does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -7,6 +8,7 @@ public record AddProgramScheduleItem(
|
||||
int ProgramScheduleId,
|
||||
StartType StartType,
|
||||
TimeSpan? StartTime,
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior,
|
||||
PlayoutMode PlayoutMode,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -7,4 +8,5 @@ public record CreateProgramSchedule(
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
|
||||
|
||||
@@ -5,19 +5,14 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public class CreateProgramScheduleHandler :
|
||||
public class CreateProgramScheduleHandler(IDbContextFactory<TvContext> dbContextFactory) :
|
||||
IRequestHandler<CreateProgramSchedule, Either<BaseError, CreateProgramScheduleResult>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public CreateProgramScheduleHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, CreateProgramScheduleResult>> Handle(
|
||||
CreateProgramSchedule request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(ps => PersistProgramSchedule(dbContext, ps));
|
||||
@@ -45,7 +40,8 @@ public class CreateProgramScheduleHandler :
|
||||
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
|
||||
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows,
|
||||
ShuffleScheduleItems = request.ShuffleScheduleItems,
|
||||
RandomStartPoint = request.RandomStartPoint
|
||||
RandomStartPoint = request.RandomStartPoint,
|
||||
FixedStartTimeBehavior = request.FixedStartTimeBehavior
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public interface IProgramScheduleItemRequest
|
||||
{
|
||||
TimeSpan? StartTime { get; }
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior { get; }
|
||||
ProgramScheduleItemCollectionType CollectionType { get; }
|
||||
int? CollectionId { get; }
|
||||
int? MultiCollectionId { get; }
|
||||
|
||||
@@ -57,6 +57,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
case PlaybackOrder.Random:
|
||||
case PlaybackOrder.MultiEpisodeShuffle:
|
||||
case PlaybackOrder.SeasonEpisode:
|
||||
case PlaybackOrder.RandomRotation:
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
|
||||
case PlaybackOrder.Shuffle:
|
||||
case PlaybackOrder.ShuffleInOrder:
|
||||
@@ -184,6 +185,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
ProgramScheduleId = programSchedule.Id,
|
||||
Index = index,
|
||||
StartTime = FixStartTime(item.StartTime),
|
||||
FixedStartTimeBehavior = item.FixedStartTimeBehavior,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
@@ -210,6 +212,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
ProgramScheduleId = programSchedule.Id,
|
||||
Index = index,
|
||||
StartTime = FixStartTime(item.StartTime),
|
||||
FixedStartTimeBehavior = item.FixedStartTimeBehavior,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
@@ -236,6 +239,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
ProgramScheduleId = programSchedule.Id,
|
||||
Index = index,
|
||||
StartTime = FixStartTime(item.StartTime),
|
||||
FixedStartTimeBehavior = item.FixedStartTimeBehavior,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
@@ -263,6 +267,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
ProgramScheduleId = programSchedule.Id,
|
||||
Index = index,
|
||||
StartTime = FixStartTime(item.StartTime),
|
||||
FixedStartTimeBehavior = item.FixedStartTimeBehavior,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -7,6 +8,7 @@ public record ReplaceProgramScheduleItem(
|
||||
int Index,
|
||||
StartType StartType,
|
||||
TimeSpan? StartTime,
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior,
|
||||
PlayoutMode PlayoutMode,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -8,4 +9,5 @@ public record UpdateProgramSchedule(
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
|
||||
|
||||
@@ -42,7 +42,8 @@ public class UpdateProgramScheduleHandler :
|
||||
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
|
||||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
|
||||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems ||
|
||||
programSchedule.RandomStartPoint != request.RandomStartPoint;
|
||||
programSchedule.RandomStartPoint != request.RandomStartPoint ||
|
||||
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior;
|
||||
|
||||
programSchedule.Name = request.Name;
|
||||
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
|
||||
@@ -50,6 +51,7 @@ public class UpdateProgramScheduleHandler :
|
||||
request.TreatCollectionsAsShows;
|
||||
programSchedule.ShuffleScheduleItems = request.ShuffleScheduleItems;
|
||||
programSchedule.RandomStartPoint = request.RandomStartPoint;
|
||||
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ internal static class Mapper
|
||||
programSchedule.KeepMultiPartEpisodesTogether,
|
||||
programSchedule.TreatCollectionsAsShows,
|
||||
programSchedule.ShuffleScheduleItems,
|
||||
programSchedule.RandomStartPoint);
|
||||
programSchedule.RandomStartPoint,
|
||||
programSchedule.FixedStartTimeBehavior);
|
||||
|
||||
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
|
||||
programScheduleItem switch
|
||||
@@ -22,6 +23,7 @@ internal static class Mapper
|
||||
duration.Index,
|
||||
duration.StartType,
|
||||
duration.StartTime,
|
||||
duration.FixedStartTimeBehavior,
|
||||
duration.CollectionType,
|
||||
duration.Collection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(duration.Collection)
|
||||
@@ -77,6 +79,7 @@ internal static class Mapper
|
||||
flood.Index,
|
||||
flood.StartType,
|
||||
flood.StartTime,
|
||||
flood.FixedStartTimeBehavior,
|
||||
flood.CollectionType,
|
||||
flood.Collection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(flood.Collection)
|
||||
@@ -129,6 +132,7 @@ internal static class Mapper
|
||||
multiple.Index,
|
||||
multiple.StartType,
|
||||
multiple.StartTime,
|
||||
multiple.FixedStartTimeBehavior,
|
||||
multiple.CollectionType,
|
||||
multiple.Collection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(multiple.Collection)
|
||||
@@ -182,6 +186,7 @@ internal static class Mapper
|
||||
one.Index,
|
||||
one.StartType,
|
||||
one.StartTime,
|
||||
one.FixedStartTimeBehavior,
|
||||
one.CollectionType,
|
||||
one.Collection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(one.Collection)
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -13,6 +14,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
|
||||
int index,
|
||||
StartType startType,
|
||||
TimeSpan? startTime,
|
||||
FixedStartTimeBehavior? fixedStartTimeBehavior,
|
||||
ProgramScheduleItemCollectionType collectionType,
|
||||
MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
@@ -40,6 +42,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
|
||||
index,
|
||||
startType,
|
||||
startTime,
|
||||
fixedStartTimeBehavior,
|
||||
PlayoutMode.Duration,
|
||||
collectionType,
|
||||
collection,
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -13,6 +14,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
|
||||
int index,
|
||||
StartType startType,
|
||||
TimeSpan? startTime,
|
||||
FixedStartTimeBehavior? fixedStartTimeBehavior,
|
||||
ProgramScheduleItemCollectionType collectionType,
|
||||
MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
@@ -37,6 +39,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
|
||||
index,
|
||||
startType,
|
||||
startTime,
|
||||
fixedStartTimeBehavior,
|
||||
PlayoutMode.Flood,
|
||||
collectionType,
|
||||
collection,
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -13,6 +14,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
|
||||
int index,
|
||||
StartType startType,
|
||||
TimeSpan? startTime,
|
||||
FixedStartTimeBehavior? fixedStartTimeBehavior,
|
||||
ProgramScheduleItemCollectionType collectionType,
|
||||
MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
@@ -38,6 +40,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
|
||||
index,
|
||||
startType,
|
||||
startTime,
|
||||
fixedStartTimeBehavior,
|
||||
PlayoutMode.Multiple,
|
||||
collectionType,
|
||||
collection,
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -13,6 +14,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
|
||||
int index,
|
||||
StartType startType,
|
||||
TimeSpan? startTime,
|
||||
FixedStartTimeBehavior? fixedStartTimeBehavior,
|
||||
ProgramScheduleItemCollectionType collectionType,
|
||||
MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
@@ -37,6 +39,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
|
||||
index,
|
||||
startType,
|
||||
startTime,
|
||||
fixedStartTimeBehavior,
|
||||
PlayoutMode.One,
|
||||
collectionType,
|
||||
collection,
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -11,6 +12,7 @@ public abstract record ProgramScheduleItemViewModel(
|
||||
int Index,
|
||||
StartType StartType,
|
||||
TimeSpan? StartTime,
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior,
|
||||
PlayoutMode PlayoutMode,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
MediaCollectionViewModel Collection,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public record ProgramScheduleViewModel(
|
||||
int Id,
|
||||
@@ -6,4 +8,5 @@ public record ProgramScheduleViewModel(
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint);
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior);
|
||||
|
||||
@@ -3,18 +3,14 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public class GetAllProgramSchedulesHandler : IRequestHandler<GetAllProgramSchedules, List<ProgramScheduleViewModel>>
|
||||
public class GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllProgramSchedules, List<ProgramScheduleViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<ProgramScheduleViewModel>> Handle(
|
||||
GetAllProgramSchedules request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.ProgramSchedules
|
||||
.Map(
|
||||
ps => new ProgramScheduleViewModel(
|
||||
@@ -23,7 +19,8 @@ public class GetAllProgramSchedulesHandler : IRequestHandler<GetAllProgramSchedu
|
||||
ps.KeepMultiPartEpisodesTogether,
|
||||
ps.TreatCollectionsAsShows,
|
||||
ps.ShuffleScheduleItems,
|
||||
ps.RandomStartPoint))
|
||||
ps.RandomStartPoint,
|
||||
ps.FixedStartTimeBehavior))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record EraseBlockPlayoutHistory(int PlayoutId) : IRequest;
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record EraseBlockPlayoutItems(int PlayoutId) : IRequest;
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record ErasePlayoutHistory(int PlayoutId) : IRequest;
|
||||
+6
-4
@@ -5,15 +5,17 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public class EraseBlockPlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<EraseBlockPlayoutHistory>
|
||||
public class ErasePlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ErasePlayoutHistory>
|
||||
{
|
||||
public async Task Handle(EraseBlockPlayoutHistory request, CancellationToken cancellationToken)
|
||||
public async Task Handle(ErasePlayoutHistory request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Filter(p => p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Block)
|
||||
.Filter(
|
||||
p => p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Block ||
|
||||
p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Yaml)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record ErasePlayoutItems(int PlayoutId) : IRequest;
|
||||
+6
-4
@@ -6,17 +6,19 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public class EraseBlockPlayoutItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<EraseBlockPlayoutItems>
|
||||
public class ErasePlayoutItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ErasePlayoutItems>
|
||||
{
|
||||
public async Task Handle(EraseBlockPlayoutItems request, CancellationToken cancellationToken)
|
||||
public async Task Handle(ErasePlayoutItems request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.PlayoutHistory)
|
||||
.Filter(p => p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Block)
|
||||
.Filter(
|
||||
p => p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Block ||
|
||||
p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Yaml)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
@@ -55,11 +55,58 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
};
|
||||
|
||||
private static Task<Validation<BaseError, Template>> Validate(TvContext dbContext, ReplaceTemplateItems request) =>
|
||||
TemplateMustExist(dbContext, request.TemplateId);
|
||||
TemplateMustExist(dbContext, request.TemplateId)
|
||||
.BindT(template => TemplateItemsMustBeValid(dbContext, template, request));
|
||||
|
||||
private static async Task<Validation<BaseError, Template>> TemplateItemsMustBeValid(
|
||||
TvContext dbContext,
|
||||
Template template,
|
||||
ReplaceTemplateItems request)
|
||||
{
|
||||
var allBlockIds = request.Items.Map(i => i.BlockId).Distinct().ToList();
|
||||
|
||||
Dictionary<int, Block> allBlocks = await dbContext.Blocks
|
||||
.AsNoTracking()
|
||||
.Filter(b => allBlockIds.Contains(b.Id))
|
||||
.ToListAsync()
|
||||
.Map(list => list.ToDictionary(b => b.Id, b => b));
|
||||
|
||||
var allTemplateItems = request.Items.Map(
|
||||
i =>
|
||||
{
|
||||
Block block = allBlocks[i.BlockId];
|
||||
return new BlockTemplateItem(
|
||||
i.BlockId,
|
||||
i.StartTime,
|
||||
i.StartTime + TimeSpan.FromMinutes(block.Minutes));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
foreach (BlockTemplateItem item in allTemplateItems)
|
||||
{
|
||||
foreach (BlockTemplateItem otherItem in allTemplateItems)
|
||||
{
|
||||
if (item == otherItem)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.StartTime < otherItem.EndTime && otherItem.StartTime < item.EndTime)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Block from {item.StartTime} to {item.EndTime} intersects block from {otherItem.StartTime} to {otherItem.EndTime}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Template>> TemplateMustExist(TvContext dbContext, int templateId) =>
|
||||
dbContext.Templates
|
||||
.Include(b => b.Items)
|
||||
.SelectOneAsync(b => b.Id, b => b.Id == templateId)
|
||||
.Map(o => o.ToValidation<BaseError>("[TemplateId] does not exist."));
|
||||
|
||||
private sealed record BlockTemplateItem(int BlockId, TimeSpan StartTime, TimeSpan EndTime);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ public record UpdateDeco(
|
||||
string Name,
|
||||
DecoMode WatermarkMode,
|
||||
int? WatermarkId,
|
||||
bool UseWatermarkDuringFiller,
|
||||
DecoMode DefaultFillerMode,
|
||||
ProgramScheduleItemCollectionType DefaultFillerCollectionType,
|
||||
int? DefaultFillerCollectionId,
|
||||
int? DefaultFillerMediaItemId,
|
||||
int? DefaultFillerMultiCollectionId,
|
||||
int? DefaultFillerSmartCollectionId,
|
||||
bool DefaultFillerTrimToFit,
|
||||
DecoMode DeadAirFallbackMode,
|
||||
ProgramScheduleItemCollectionType DeadAirFallbackCollectionType,
|
||||
int? DeadAirFallbackCollectionId,
|
||||
|
||||
@@ -26,6 +26,25 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
// watermark
|
||||
existing.WatermarkMode = request.WatermarkMode;
|
||||
existing.WatermarkId = request.WatermarkMode is DecoMode.Override ? request.WatermarkId : null;
|
||||
existing.UseWatermarkDuringFiller =
|
||||
request.WatermarkMode is DecoMode.Override && request.UseWatermarkDuringFiller;
|
||||
|
||||
// default filler
|
||||
existing.DefaultFillerMode = request.DefaultFillerMode;
|
||||
existing.DefaultFillerCollectionType = request.DefaultFillerCollectionType;
|
||||
existing.DefaultFillerCollectionId = request.DefaultFillerMode is DecoMode.Override
|
||||
? request.DefaultFillerCollectionId
|
||||
: null;
|
||||
existing.DefaultFillerMediaItemId = request.DefaultFillerMode is DecoMode.Override
|
||||
? request.DefaultFillerMediaItemId
|
||||
: null;
|
||||
existing.DefaultFillerMultiCollectionId = request.DefaultFillerMode is DecoMode.Override
|
||||
? request.DefaultFillerMultiCollectionId
|
||||
: null;
|
||||
existing.DefaultFillerSmartCollectionId = request.DefaultFillerMode is DecoMode.Override
|
||||
? request.DefaultFillerSmartCollectionId
|
||||
: null;
|
||||
existing.DefaultFillerTrimToFit = request.DefaultFillerTrimToFit;
|
||||
|
||||
// dead air fallback
|
||||
existing.DeadAirFallbackMode = request.DeadAirFallbackMode;
|
||||
|
||||
@@ -9,6 +9,14 @@ public record DecoViewModel(
|
||||
string Name,
|
||||
DecoMode WatermarkMode,
|
||||
int? WatermarkId,
|
||||
bool UseWatermarkDuringFiller,
|
||||
DecoMode DefaultFillerMode,
|
||||
ProgramScheduleItemCollectionType DefaultFillerCollectionType,
|
||||
int? DefaultFillerCollectionId,
|
||||
int? DefaultFillerMediaItemId,
|
||||
int? DefaultFillerMultiCollectionId,
|
||||
int? DefaultFillerSmartCollectionId,
|
||||
bool DefaultFillerTrimToFit,
|
||||
DecoMode DeadAirFallbackMode,
|
||||
ProgramScheduleItemCollectionType DeadAirFallbackCollectionType,
|
||||
int? DeadAirFallbackCollectionId,
|
||||
|
||||
@@ -56,6 +56,14 @@ internal static class Mapper
|
||||
deco.Name,
|
||||
deco.WatermarkMode,
|
||||
deco.WatermarkId,
|
||||
deco.UseWatermarkDuringFiller,
|
||||
deco.DefaultFillerMode,
|
||||
deco.DefaultFillerCollectionType,
|
||||
deco.DefaultFillerCollectionId,
|
||||
deco.DefaultFillerMediaItemId,
|
||||
deco.DefaultFillerMultiCollectionId,
|
||||
deco.DefaultFillerSmartCollectionId,
|
||||
deco.DefaultFillerTrimToFit,
|
||||
deco.DeadAirFallbackMode,
|
||||
deco.DeadAirFallbackCollectionType,
|
||||
deco.DeadAirFallbackCollectionId,
|
||||
@@ -108,5 +116,5 @@ internal static class Mapper
|
||||
Playouts.Mapper.GetDisplayTitle(playoutItem),
|
||||
playoutItem.StartOffset.TimeOfDay,
|
||||
playoutItem.FinishOffset.TimeOfDay,
|
||||
Playouts.Mapper.GetDisplayDuration(playoutItem.FinishOffset - playoutItem.StartOffset));
|
||||
playoutItem.GetDisplayDuration());
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user