Compare commits

...

95 Commits

Author SHA1 Message Date
Jason Dove
cd0219c5c3 update changelog for release v0.8.5-beta [no ci] 2024-01-30 15:34:28 -06:00
Jason Dove
4cf8b83de4 ignore subtitles when they are unavailable (#1583) 2024-01-30 14:29:13 -06:00
Jason Dove
6923b25177 add more log level switches (#1582)
* label block and json playouts as experimental

* add more log level switches
2024-01-30 13:10:19 -06:00
Jason Dove
5dce905b8e clear block playout items without clearing history (#1581) 2024-01-30 09:10:49 -06:00
Jason Dove
46c26b5ea7 include all block playout items in xmltv (#1580)
* include all block playout items in xmltv

* double check whether channel preview will work
2024-01-30 06:44:19 -06:00
Jason Dove
7fffc8cf63 channel preview player (#1579)
* add channel preview

* add button to stop transcoding session
2024-01-29 20:52:52 -06:00
Jason Dove
18deff0b83 add session api endpoints (#1578) 2024-01-29 11:31:16 -06:00
Jason Dove
16007a888e fix actions and changelog (#1576) 2024-01-27 10:14:53 -06:00
Jason Dove
7eb1227ba4 fix action version (#1575) 2024-01-26 06:17:55 -06:00
Jason Dove
1d1d5bf9bc use software overlay for intermittent watermark on nvidia (#1574)
* use software overlay for intermittent watermark on nvidia

* update some github action versions

* update changelog
2024-01-26 06:16:00 -06:00
Jason Dove
45c04366c9 remove dynaudnorm filter (#1573) 2024-01-25 19:56:14 -06:00
Jason Dove
60b3bc92f4 use super shuffle in block playouts (#1572) 2024-01-24 15:29:19 -06:00
Jason Dove
12234c3e21 allow shuffling block items (#1571)
* allow shuffling block items

* fix drop down search results
2024-01-23 22:42:28 -06:00
Jason Dove
d37ce2d38a update xmltv channel list on channel edit (#1570) 2024-01-23 13:10:52 -06:00
Jason Dove
6f49233864 fix image upload corruption (#1569) 2024-01-23 10:49:19 -06:00
Will
a67a6047c1 Update README.md (#1567)
Remove the link for Hardware-accelerated transcoding which was just linking back to itself
2024-01-22 14:33:16 -06:00
Jason Dove
33f67b88f0 show chapter markers in media info (#1568) 2024-01-22 14:19:35 -06:00
Jason Dove
b88deaafe5 add tests, replace playout items when collections are updated (#1566) 2024-01-22 10:10:22 -06:00
Jason Dove
83fc3081d8 add some logging around playlist trimming (#1565) 2024-01-22 05:47:00 -06:00
Jason Dove
15d4b0f82b remove v2 ui and node (#1564) 2024-01-16 13:28:46 -06:00
Jason Dove
88fac0de04 add option to stop scheduling before or after block duration end (#1563) 2024-01-16 12:53:56 -06:00
Jason Dove
4805d0d40f add button to copy block item (#1562) 2024-01-16 10:30:50 -06:00
Jason Dove
ef3b941a39 fix mysql migration (#1561)
* clean up block playout preview logic

* fix some bugs with playout templates editor

* fix mysql migration
2024-01-16 05:40:29 -06:00
Jason Dove
a59f71039c preview block playout in block editor (#1558)
* block editor cleanup

* preview block playout

* cleanup
2024-01-15 19:39:18 -06:00
Jason Dove
1ad42fffb1 fix mac builds (#1557) 2024-01-15 10:29:07 -06:00
Jason Dove
2ce8db9e01 basic block duration enforcement (#1556) 2024-01-15 06:26:14 -06:00
Jason Dove
c409fd8b47 fix playout build hang for block playouts (#1555) 2024-01-14 19:40:29 -06:00
Jason Dove
907b8074f1 allow more collection types and playback orders in blocks (#1554) 2024-01-14 12:51:45 -06:00
Jason Dove
adbd0bcec0 block schedule refactor (#1553)
* erase block playout history and items from playouts page

* remove block from template

* refactor block scheduling; improve history behavior
2024-01-14 10:22:04 -06:00
Jason Dove
2c4379886a limit blocks to television shows and seasons (#1551) 2024-01-14 06:46:38 -06:00
Jason Dove
caef4a139e block scheduling skip unchanged blocks (#1550)
* schedule blocks in order

* block minutes must be multiple of 15

* improve block minutes entry, validation and display

* confirm deleting blocks and block groups

* confirm deleting templates and template groups

* skip unchanged blocks in playout
2024-01-14 06:16:53 -06:00
Jason Dove
dcbe4837bf first pass at block scheduling (#1548)
* add blocks, block groups

* basic block and block item editing

* add template groups and basic template editing (name)

* add blocks to template calendar

* edit playout templates

* add calendar preview to playout templates

* add basic block playout building

* add mysql migration

* update changelog
2024-01-13 22:01:21 -06:00
Jason Dove
5e530b9301 fix scale behavior crop with qsv (#1546) 2024-01-12 13:21:49 -06:00
Jason Dove
2a28bf68bf fix crop mode with nvidia accel (#1545) 2024-01-11 11:42:26 -06:00
Jason Dove
f39eac97c0 fix fill with group when show is also included individually (#1544) 2024-01-11 10:44:50 -06:00
Jason Dove
9fd6589831 disambiguate seasons (#1543) 2024-01-11 09:08:52 -06:00
Jason Dove
e2a516f5e8 fix external json playouts with mysql (#1542) 2024-01-09 05:26:57 -06:00
Jason Dove
64502315a3 generate xmltv for external json playouts (#1541) 2024-01-08 20:54:40 -06:00
Jason Dove
56bc58fce9 reorganize to fix build (#1540) 2024-01-08 20:00:35 -06:00
Jason Dove
0330b9326d add external json playout type for dizquetv interop (#1539)
* add external json playout

* basic local playback works

* fallback to streaming from plex

* update external json file

* update changelog
2024-01-08 19:45:43 -06:00
Jason Dove
6708d6b4d7 support filling with groups of song artists (#1537) 2024-01-05 10:32:04 -06:00
Jason Dove
c18be5559b fix delete old segments (#1536)
* code cleanup

* ignore errors deleting old hls segments
2024-01-04 10:42:04 -06:00
Jason Dove
18ed20e203 fix multiple zero when using fill with group (#1535) 2024-01-02 15:29:50 -06:00
Jason Dove
965c7d0eac update changelog [no ci] 2024-01-02 10:34:57 -06:00
Jason Dove
545bf1b775 fill with group (#1534)
* use browser's accept-language header

* add fill with group mode to schedule items

* update dependencies

* fixes

* fix tests
2024-01-02 10:18:49 -06:00
Jason Dove
bb299d4ee7 maybe these don't need npm (#1533) 2023-12-30 20:13:11 -06:00
Jason Dove
0e6c7d2bc3 fix npm in docker builds (#1532) 2023-12-30 20:03:29 -06:00
Jason Dove
576f0cd7e7 more dotnet 8 fixes (#1530) 2023-12-30 13:47:45 -06:00
Jason Dove
9471cb55dd upgrade from dotnet 7 to dotnet 8 (#1529)
* upgrade sdk

* fix warnings in ersatztv.ffmpeg

* fix warnings in ersatztv.core

* fix warnings in ersatztv.infrastructure

* fix warnings in ersatztv.application

* disable analysis for migrations projects

* fix warnings in ersatztv.scanner

* fix warnings in ersatztv

* upgrade project framework

* update github actions and dockerfiles
2023-12-30 13:29:57 -06:00
Jason Dove
3a84af1626 update dependencies (#1527) 2023-12-27 04:44:30 -06:00
Jason Dove
3d3bb64844 fix path replacements page with mysql (#1521) 2023-12-11 17:54:15 -06:00
Jason Dove
8fc1f36638 use explorer to open logs folder on windows (#1520) 2023-12-05 18:28:10 -06:00
Jason Dove
1823a5bae5 update changelog for release v0.8.4-beta [no ci] 2023-12-02 16:45:33 -06:00
Jason Dove
fc871e6f74 fix detection of amf hw accel on windows (#1519) 2023-12-02 09:05:02 -06:00
Jason Dove
24780cbe84 fix disappearing collection tags (#1517) 2023-11-30 20:31:37 -06:00
Jason Dove
c6ed258021 validate filler mode pad settings (#1516) 2023-11-26 12:54:06 -06:00
Jason Dove
7586647b73 fix ffmpeg version health check on windows (#1515) 2023-11-23 06:05:49 -06:00
Jason Dove
d91e945124 update changelog for release v0.8.3-beta [no ci] 2023-11-22 11:36:31 -06:00
Jason Dove
9dabffbac1 support more formats for show fallback metadata (#1514) 2023-11-21 15:52:25 -06:00
Jason Dove
d310b5c09d fix nvidia hardware decoding on windows (#1513) 2023-11-21 06:36:05 -06:00
Jason Dove
ba48b3a676 update dependencies (#1512) 2023-11-20 21:57:43 -06:00
Jason Dove
d8a51b5d6d fix season display bug (#1511) 2023-11-20 21:17:11 -06:00
Jason Dove
97674cff89 fix bug scheduling duration filler (#1510) 2023-11-20 21:02:26 -06:00
Jason Dove
4820615308 proper fix to the sdk mismatch (#1509) 2023-11-16 13:37:20 -06:00
Jason Dove
1ddf27ce88 pin dotnet sdk version used in github actions (#1508) 2023-11-16 13:21:51 -06:00
Jason Dove
cd98a89acd enable docker arm builds again (#1507) 2023-11-16 13:07:49 -06:00
Jason Dove
a2a6afc3e3 temp disable arm docker builds (#1506) 2023-11-16 09:58:46 -06:00
Jason Dove
dfaba8c7b0 use release version of ffmpeg 6.1 (#1505) 2023-11-16 09:57:13 -06:00
Jason Dove
5d11a6b46f use separate model for plex collection scanning since the api types are inconsistent (#1504) 2023-11-16 06:43:48 -06:00
Jason Dove
b95a89b11f plex collection rework (#1503)
* start to rework plex collection scanning

* sync plex collections to db

* sync plex collection items

* update changelog
2023-11-14 10:41:21 -06:00
Jason Dove
948b3735bd fix file not found music videos (#1502)
* fix indexing music videos in file not found state

* update dependencies
2023-11-14 05:50:51 -06:00
Jason Dove
5ecf271773 fix jellyfin library scan (#1501)
* update dependencies

* fix jellyfin library scan
2023-11-10 06:26:23 -06:00
Jason Dove
b287c0d6ec add jellyfin season number fallback (#1497) 2023-11-06 09:37:12 -06:00
Jason Dove
b667659c05 use notarytool directly instead of gon (#1493) 2023-11-05 07:46:15 -06:00
Jason Dove
22d3025e8e include noto cjk fonts in docker (#1492) 2023-11-05 06:15:57 -06:00
Jason Dove
8f5b181372 mysql media server library scan fixes (#1491)
* fix some mysql movie library updates

* fix some mysql show library updates

* update dependencies
2023-10-30 06:45:00 -05:00
Jason Dove
f5060522aa windows nvidia h264 workaround (#1487)
* work around bad h264_cuvid behavior on windows with ffmpeg snapshot

* use latest ffmpeg build on windows

* nvdec => cuda
2023-10-16 11:40:12 -05:00
Jason Dove
14a88bd225 optimize ffmpeg capability cache (#1486)
* minimize cached ffmpeg capabilities

* use set intersect

* try disabling work ahead on nvidia/windows
2023-10-16 08:42:26 -05:00
Jason Dove
0550c60a78 allow older ffmpeg for testing (#1485)
* allow older ffmpeg for testing

* use proper option name
2023-10-14 21:13:18 -05:00
Jason Dove
d3bdcf9bc4 allow plex personal media show libraries (#1483) 2023-10-13 13:33:10 -05:00
Jason Dove
714f68a887 add language_tag and seconds fields to search index (#1479)
* add `language_tag` and `seconds` fields to search index

* simplify
2023-10-10 20:36:50 -05:00
Jason Dove
17bed524f2 fix ui display of multiple languages (#1474) 2023-10-08 18:21:48 -05:00
Jason Dove
c3fe263978 validate hardware accel, use hw accel for error messages (#1471)
* only display supported hw accels in ffmpeg profile editor

* qsv capability improvements

* qsv fixes

* update changelog
2023-10-08 11:21:04 -05:00
Jason Dove
5291832e6c fix clipboard and logs (#1466)
* fix copy to clipboard in some cases

* improve subtitle language selection logging

* log playout item details
2023-10-06 19:36:42 -05:00
Jason Dove
b39dd693f0 update dependencies and windows ffmpeg (#1462)
* update dependencies

* update windows ffmpeg version
2023-10-05 19:14:06 -05:00
Jason Dove
46bf9ef990 fix intel vaapi pgs subtitle pixel format (#1455) 2023-09-30 13:10:23 -05:00
Jason Dove
bc845b1327 schedule filler using ticks instead of milliseconds (#1454)
* add script to set db provider

* don't extract embedded subtitles with DEBUG_NO_SYNC

* fix playout filler precision bug
2023-09-30 06:41:15 -05:00
Jason Dove
3ab8e5bc3a optimize jellyfin collection scanning (#1453) 2023-09-29 09:47:57 -05:00
Jason Dove
e8bc051f73 transcoding improvements (#1452)
* use noautoscale with vaapi encoder

* only use one input file for vaapi with radeonsi driver

* fix vaapi 8-bit to 10-bit

* fix nvidia subtitle scaling

* optimize nvidia subtitle scaling

* fix test pgs subtitle
2023-09-29 06:29:59 -05:00
Jason Dove
b008fcfd85 fix scheduling precision error (#1451)
* fix scheduling precision error

* update dependencies
2023-09-27 06:07:48 -05:00
Jason Dove
547db5fb51 add kodiprop to channels.m3u (#1448) 2023-09-26 15:47:55 -05:00
Jason Dove
58fae1b0cc add crop scaling behavior (#1443)
* add scaling behavior - crop

* fix ffmpeg version check on windows (snapshot)

* update dependencies
2023-09-22 08:23:49 -05:00
Jason Dove
694b6bbd91 scaling behavior and normalize loudness (#1439)
* update changelog [no ci]

* add ffmpeg profile scaling behavior

* update dependencies

* add normalize loudness mode

* update changelog
2023-09-21 02:46:43 -05:00
Jason Dove
e0f8b7d7ae use ffmpeg 6.1 snapshot for windows (#1435) 2023-09-14 19:33:40 -05:00
Jason Dove
b16215fcd6 improve hls throttle (#1434)
* throttle using ffmpeg option

* update ffmpeg version
2023-09-14 19:28:15 -05:00
651 changed files with 207226 additions and 27260 deletions

View File

@@ -47,19 +47,9 @@ jobs:
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: 7.0.x
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '14'
- name: Cache NPM dependencies
uses: bahmutov/npm-install@v1.8.28
with:
working-directory: ErsatzTV/client-app
dotnet-version: 8.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -68,7 +58,7 @@ jobs:
run: dotnet restore -r "${{ matrix.target}}"
- name: Import Code-Signing Certificates
uses: Apple-Actions/import-codesign-certs@v1
uses: Apple-Actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.apple_developer_certificate_p12_base64 }}
p12-password: ${{ secrets.apple_developer_certificate_password }}
@@ -83,8 +73,8 @@ jobs:
shell: bash
run: |
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -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 publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Bundle
shell: bash
@@ -118,12 +108,8 @@ jobs:
- name: Notarize
shell: bash
run: |
brew tap mitchellh/gon
brew install mitchellh/gon/gon
gon -log-level=debug -log-json ./gon.json
env:
AC_USERNAME: ${{ secrets.ac_username }}
AC_PASSWORD: ${{ secrets.ac_password }}
xcrun notarytool submit ErsatzTV.dmg --apple-id "${{ secrets.ac_username }}" --password "${{ secrets.ac_password }}" --team-id 32MB98Q32R --wait
xcrun stapler staple ErsatzTV.dmg
- name: Cleanup
shell: bash
@@ -177,25 +163,9 @@ jobs:
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: 7.0.x
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '14'
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
if: ${{ matrix.kind == 'windows' }}
- name: Cache NPM dependencies
uses: bahmutov/npm-install@v1.8.28
with:
working-directory: ErsatzTV/client-app
dotnet-version: 8.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -208,7 +178,7 @@ jobs:
id: downloadffmpeg
name: Download ffmpeg
with:
url: "https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.7z"
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/6.1-working-cuvid/ffmpeg-6.1-working-cuvid.7z"
target: ffmpeg/
- name: Build
@@ -220,8 +190,8 @@ jobs:
# Build everything
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.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 net7.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 "$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
# Build Windows launcher
if [ "${{ matrix.kind }}" == "windows" ]; then
@@ -245,9 +215,6 @@ jobs:
# Delete output directory
rm -r "$release_name"
env:
AC_USERNAME: ${{ secrets.ac_username }}
AC_PASSWORD: ${{ secrets.ac_password }}
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
@@ -259,6 +226,7 @@ jobs:
assets: |
*${{ matrix.target }}.zip
*${{ matrix.target }}.tar.gz
- name: Publish
uses: softprops/action-gh-release@v1
with:

View File

@@ -54,21 +54,21 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
if: ${{ matrix.qemu == true }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
id: docker-buildx
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v5
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
@@ -82,7 +82,7 @@ jobs:
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v5
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
@@ -97,7 +97,7 @@ jobs:
if: ${{ matrix.name == 'arm64' }}
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v5
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .

View File

@@ -9,14 +9,9 @@ jobs:
uses: actions/checkout@v4
- name: Setup .NET Core
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: 7.0.x
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
dotnet-version: 8.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -31,7 +26,7 @@ jobs:
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --no-restore --verbosity normal
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
- name: Build Windows
run: |
@@ -44,9 +39,9 @@ jobs:
uses: actions/checkout@v4
- name: Setup .NET Core
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: 7.0.x
dotnet-version: 8.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -61,7 +56,7 @@ jobs:
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --no-restore --verbosity normal
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
build_and_test_mac:
runs-on: macos-11
steps:
@@ -72,9 +67,9 @@ jobs:
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: 7.0.x
dotnet-version: 8.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -89,4 +84,4 @@ jobs:
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --no-restore --verbosity normal
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal

View File

@@ -1,22 +0,0 @@
name: Lint VueJS Files on PR Request
on:
pull_request:
jobs:
vue-lint:
runs-on: ubuntu-latest
steps:
# Checkout the current repo
- name: Checkout current repository
uses: actions/checkout@v4
# Setup NodeJS version 16
- name: Setup NodeJS V16.x.x
uses: actions/setup-node@v3
with:
node-version: '16'
# CD into the current client directory and lint and build the client
- name: Lint and Build the client
run: |
cd ./ErsatzTV/client-app/
npm ci --no-optional
npm run lint
npm run build --if-present

View File

@@ -5,6 +5,138 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.8.5-beta] - 2024-01-30
### Added
- Respect browser's `Accept-Language` header for date time display
- Add new schedule item setting `Fill With Group Mode`
- This setting is only available when a `Collection`, `Multi-Collection` or `Smart Collection` is scheduled with `Duration` or `Multiple` playout modes
- Use this setting when you want to schedule a collection containing groups (show or artists), with only videos from a single group (show or artist) being used in each rotation
- The options are `None`, `Ordered Groups` and `Shuffled Groups`:
- `None`: no change to scheduling behavior - all groups (shows and artists) will be shuffled/ordered together
- `Ordered Groups`: each time this item is scheduled, the entire `Duration` or `Multiple` will be filled with a single group, and the groups will rotate in a fixed order
- `Shuffled Groups`: each time this item is scheduled, the entire `Duration` or `Multiple` will be filled with a single group, and the groups will rotate in a shuffled order
- Add new playout type `External Json`
- Use this playout type when you want to manage the channel schedule using DizqueTV
- You must point ErsatzTV to the channel number json file from DizqueTV, e.g. `channels/1.json`
- For playback, ErsatzTV will first check for the appropriate media file file locally
- If found, ErsatzTV will run ffprobe to get statistics immediately before streaming from disk
- When local files are unavailable, ErsatzTV must be logged into the same Plex server as DizqueTV
- ErsatzTV will ask Plex for statistics immediately before streaming from Plex
- Add new *experimental* playout type `Block`
- **This playout type is under active development and updates may reset or delete related playout data**
- Many planned features are missing, incomplete, or result in errors. This is expected.
- Block playouts consist of:
- `Blocks` - ordered list of items to play within the specified duration
- `Templates` - a generic "day" that consists of blocks scheduled at specific times
- `Playout Templates` - templates to schedule using the specified criteria. Only one template will be selected each day
- Much more to come on this feature as development continues
- Show chapter markers in movie and episode media info
- Add two new API endpoints for interacting with transcoding sessions (MPEG-TS and HLS Segmenter):
- GET `/api/sessions`
- Show brief info about all active sessions
- DELETE `/api/session/{channel-number}`
- Stop the session for the given channel number
- Add channel preview (web-based video player)
- Channels MUST use `H264` video format and `AAC` audio format
- Channels MUST use `MPEG-TS` or `HLS Segmenter` streaming modes
- Since `MPEG-TS` uses `HLS Segmenter` under the hood, the preview player will use `HLS Segmenter`, so it's not 100% equivalent, but it should be representative
- Add button to stop transcoding session for each channel that has an active session
- Add more log levels to `Settings` page, allowing more specific debug logging as needed
- Default Minimum Log Level (applies when no other categories/level overrides match)
- Scanning Minimum Log Level
- Scheduling Minimum Log Level
- Streaming Minimum Log Level
### Fixed
- Fix error loading path replacements when using MySql
- Fix tray icon shortcut to open logs folder on Windows
- Unlock playout when playout build fails
- Ignore errors deleting old HLS segments; this should improve stream reliability
- Update show year when changed within Plex
- Fix crop scale behavior with NVIDIA, QSV acceleration
- Fix bug that corrupted uploaded images (watermarks, channel logos)
- Re-uploading images should fix them
- Recreate XMLTV channel list (including logos) when channels are edited in ErsatzTV
- This bug caused the ErsatzTV logo to be used instead of channel logos in some cases
- Update drop down search results in main search bar when items are created/edited/removed
- Fix green line at bottom of video when NVIDIA accel is used with intermittent watermark
- Fix error starting streaming session when subtitles are still being extracted for the current item
### Changed
- Upgrade from .NET 7 to .NET 8
- In schedule items, disambiguate seasons from shows with the same title by including show year
- Old format: `Show Title (Season Number)`
- New format: `Show Title (Show Year) - Season Number`
- Remove FFmpeg Profile `Normalize Loudness` option `dynaudnorm` as it often caused streams to fail to start
- Disable loudness normalization by default in new FFmpeg Profiles
- Use AAC audio format by default in new FFmpeg Profiles
## [0.8.4-beta] - 2023-12-02
### Fixed
- Fix playout builder crash with improperly configured pad filler preset
- Properly validate filler preset mode pad to require `filler pad to nearest minute` value
- Fix bug where previously-synchronized collection tags would disappear
- This bug affected Jellyfin, Emby and Plex collections
- Fix detection of AMF hardware acceleration on Windows
## [0.8.3-beta] - 2023-11-22
### Added
- Add `Scaling Behavior` option to FFmpeg Profile
- `Scale and Pad`: the default behavior and will maintain aspect ratio of all content
- `Stretch`: a new mode that will NOT maintain aspect ratio when normalizing source content to the desired resolution
- `Crop`: a new mode that will scale beyond the desired resolution (maintaining aspect ratio), and crop to desired resolution
- **This mode does NOT detect black and intelligently crop**
- The goal is to fill the canvas by over-scaling and cropping, instead of minimally scaling and padding
- Include `inputstream.ffmpegdirect` properties in channels.m3u when requested by Kodi
- Log playout item title and path when starting a stream
- This will help with media server libraries where the URL passed to ffmpeg doesn't indicate which file is streaming
- Add QSV Capabilities to Troubleshooting page
- Add `language_tag` and `seconds` fields to search index
- Allow synchronizing Plex `TV Show` libraries that use `Personal Media Shows` agent
- Include Noto CJK Fonts in docker images to support those characters in generated subtitles like songs and music video credits
- Support show fallback metadata with folder names like `Show.Name(1992)`
### Fixed
- Fix playout bug that caused some schedule items with fixed start times to be pushed to the next day
- Fix playout bug that prevented padded durations from fitting within a schedule item of the same duration
- For example, filler that padded to 30 minutes would often not fit in a 30 minute duration schedule item
- Fix VAAPI transcoding 8-bit source content to 10-bit
- Fix NVIDIA subtitle scaling when `scale_npp` filter is unavailable
- Remove ffmpeg and ffprobe as required dependencies for scanning media server libraries
- Note that ffmpeg is still *always* required for playback to work
- Fix PGS subtitle pixel format with Intel VAAPI
- Fix some cases where `Copy` button would fail to copy to clipboard
- Fix some cases where ffmpeg process would remain running after properly closing ErsatzTV
- Fix QSV HLS segment duration
- This behavior caused extremely slow QSV stream starts
- Fix displaying multiple languages in UI for movies, artists, shows
- Fix MySQL queries that could fail during media server library scans
- Fix scanning Jellyfin libraries when library options and/or path infos are not returned from Jellyfin API
- Fix error indexing music videos in `File Not Found` state
- Fix bug scheduling duration filler when filler collection contains item with zero duration
- Fix bug displaying television seasons for shows that have no year metadata
### Changed
- Upgrade ffmpeg to 6.1, which is now *required* for all installs
- Use new ffmpeg throttling method to minimize cpu/gpu use without impacting audio normalization
- Change FFmpeg Profile `Normalize Loudness` setting from checkbox to dropdown
- `Off`: do not normalize loudness
- `loudnorm`: use `loudnorm` filter to normalize loudness (generally higher CPU use)
- `dynaudnorm`: use `dynaudnorm` filter to normalize loudness (generally lower CPU use)
- Jellyfin collection scanning will no longer happen after every (automatic or forced) library scan
- Automatic/periodic scans will check collections one time after all libraries have been scanned
- There is a new table in the `Media` > `Libraries` page with a button to manually re-scan Jellyfin collections as needed
- In FFmpeg Profile editor, only display hardware acceleration kinds that are supported by the configured ffmpeg
- Test QSV acceleration if configured, and fallback to software mode if test fails
- Detect QSV capabilities on Linux (supported decoders, encoders)
- Use hardware acceleration for error messages/offline messages
- Try to parse season number from season folder when Jellyfin does not provide season number
- This *may* fix issues where Jellyfin libraries show all season numbers as 0 (specials)
- Rework Plex collection scanning
- Automatic/periodic scans will check collections one time after all libraries have been scanned
- There is a table in the `Media` > `Libraries` page with a button to manually re-scan Plex collections as needed
- Plex smart collections will now be synchronized as tags, similar to other Plex collections
## [0.8.2-beta] - 2023-09-14
### Added
- Automatically rebuild search index after improper shutdown
@@ -1741,7 +1873,10 @@ 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.2-beta...HEAD
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.5-beta...HEAD
[0.8.5-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.4-beta...v0.8.5-beta
[0.8.4-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.3-beta...v0.8.4-beta
[0.8.3-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.2-beta...v0.8.3-beta
[0.8.2-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.1-beta...v0.8.2-beta
[0.8.1-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.0-beta...v0.8.1-beta
[0.8.0-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.9-beta...v0.8.0-beta

View File

@@ -1,5 +1,6 @@
<Project>
<PropertyGroup>
<InformationalVersion>develop</InformationalVersion>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -43,10 +43,7 @@ fn main() {
None => {}
Some(folder) => {
fs::create_dir_all(folder).unwrap();
let _ = Command::new("cmd")
.creation_flags(CREATE_NO_WINDOW)
.arg("/C")
.arg("start")
let _ = Command::new("explorer.exe")
.arg(folder)
.stdin(Stdio::null())
.stdout(Stdio::null())

View File

@@ -29,12 +29,11 @@ internal static class Mapper
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.Distinct()
.ToList();
}
}

View File

@@ -1,33 +1,38 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
public class CreateChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets)
: IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateChannelHandler(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, CreateChannelResult>> Handle(
CreateChannel request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistChannel(dbContext, c));
}
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
{
await dbContext.Channels.AddAsync(channel);
await dbContext.SaveChangesAsync();
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(new RefreshChannelList());
return new CreateChannelResult(channel.Id);
}

View File

@@ -1,6 +1,7 @@
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -12,30 +13,34 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly ISearchTargets _searchTargets;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public DeleteChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem)
ILocalFileSystem localFileSystem,
ISearchTargets searchTargets)
{
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request);
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c, cancellationToken));
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
}
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
{
dbContext.Channels.Remove(channel);
await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
// delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");

View File

@@ -6,10 +6,12 @@ using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using Newtonsoft.Json;
namespace ErsatzTV.Application.Channels;
@@ -38,7 +40,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<PlayoutItem> sorted = await dbContext.Playouts
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
.Include(p => p.Items)
@@ -85,10 +87,25 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.ToListAsync(cancellationToken)
.Map(list => list.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList());
.ToListAsync(cancellationToken);
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
List<PlayoutItem> sorted = [];
foreach (Playout playout in playouts)
{
switch (playout.ProgramSchedulePlayoutType)
{
case ProgramSchedulePlayoutType.Flood:
case ProgramSchedulePlayoutType.Block:
sorted.AddRange(playouts.Collect(p => p.Items).OrderBy(pi => pi.Start));
break;
case ProgramSchedulePlayoutType.ExternalJson:
sorted.AddRange(await CollectExternalJsonItems(playout.ExternalJsonFile));
break;
}
}
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
@@ -374,6 +391,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
_ => 440
};
if (artworkPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || artworkPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return artworkPath;
}
if (artworkPath.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
@@ -492,7 +513,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string[] split = first.Split(':');
if (split.Length == 2)
{
return split[0].ToLowerInvariant() == "us"
return split[0].Equals("us", StringComparison.OrdinalIgnoreCase)
? new ContentRating(system, split[1].ToUpperInvariant())
: new ContentRating(None, split[1].ToUpperInvariant());
}
@@ -521,5 +542,147 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return maybeArtwork.IfNone(string.Empty);
}
private async Task<List<PlayoutItem>> CollectExternalJsonItems(string path)
{
var result = new List<PlayoutItem>();
if (_localFileSystem.FileExists(path))
{
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
await File.ReadAllTextAsync(path));
// must deserialize channel from json
foreach (ExternalJsonChannel channel in maybeChannel)
{
// TODO: null start time should log and throw
DateTimeOffset startTime = DateTimeOffset.Parse(
channel.StartTime ?? string.Empty,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal).ToLocalTime();
for (var i = 0; i < channel.Programs.Length; i++)
{
ExternalJsonProgram program = channel.Programs[i];
int milliseconds = program.Duration;
DateTimeOffset nextStart = startTime + TimeSpan.FromMilliseconds(milliseconds);
if (program.Duration >= channel.GuideMinimumDurationSeconds * 1000)
{
result.Add(BuildPlayoutItem(startTime, program, i));
}
startTime = nextStart;
}
}
}
return result;
}
private static PlayoutItem BuildPlayoutItem(DateTimeOffset startTime, ExternalJsonProgram program, int count)
{
MediaItem mediaItem = program.Type switch
{
"episode" => BuildEpisode(program),
_ => BuildMovie(program)
};
return new PlayoutItem
{
Start = startTime.UtcDateTime,
Finish = startTime.AddMilliseconds(program.Duration).UtcDateTime,
FillerKind = FillerKind.None,
ChapterTitle = null,
GuideFinish = null,
GuideGroup = count,
CustomTitle = null,
InPoint = TimeSpan.Zero,
OutPoint = TimeSpan.FromMilliseconds(program.Duration),
MediaItem = mediaItem
};
}
private static Episode BuildEpisode(ExternalJsonProgram program)
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(program.Icon))
{
artwork.Add(new Artwork
{
ArtworkKind = ArtworkKind.Thumbnail,
Path = program.Icon,
SourcePath = program.Icon
});
}
return new Episode
{
MediaVersions =
[
new MediaVersion
{
Duration = TimeSpan.FromMilliseconds(program.Duration)
}
],
EpisodeMetadata =
[
new EpisodeMetadata
{
EpisodeNumber = program.Episode,
Title = program.Title
},
],
Season = new Season
{
SeasonNumber = program.Season,
Show = new Show
{
ShowMetadata =
[
new ShowMetadata
{
Title = program.ShowTitle,
Artwork = artwork
}
]
}
}
};
}
private static Movie BuildMovie(ExternalJsonProgram program)
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(program.Icon))
{
artwork.Add(new Artwork
{
ArtworkKind = ArtworkKind.Poster,
Path = program.Icon,
SourcePath = program.Icon
});
}
return new Movie
{
MediaVersions =
[
new MediaVersion
{
Duration = TimeSpan.FromMilliseconds(program.Duration)
}
],
MovieMetadata =
[
new MovieMetadata
{
Title = program.Title,
Year = program.Year,
Artwork = artwork
}
]
};
}
private sealed record ContentRating(Option<string> System, string Value);
}

View File

@@ -31,7 +31,7 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });

View File

@@ -4,6 +4,7 @@ using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -12,26 +13,19 @@ using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
public class UpdateChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets)
: IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdateChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, ChannelViewModel>> Handle(
UpdateChannel request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
@@ -77,6 +71,8 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync();
searchTargets.SearchTargetsChanged();
if (c.SubtitleMode != ChannelSubtitleMode.None)
{
@@ -85,9 +81,11 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
foreach (Playout playout in maybePlayout)
{
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
}
}
await workerChannel.WriteAsync(new RefreshChannelList());
return ProjectToViewModel(c);
}

View File

@@ -86,7 +86,7 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
return result;
}
if (distinct.Any())
if (distinct.Count != 0)
{
_logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",

View File

@@ -2,5 +2,10 @@ using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public record GetChannelPlaylist
(string Scheme, string Host, string BaseUrl, string Mode, string AccessToken) : IRequest<ChannelPlaylist>;
public record GetChannelPlaylist(
string Scheme,
string Host,
string BaseUrl,
string Mode,
string UserAgent,
string AccessToken) : IRequest<ChannelPlaylist>;

View File

@@ -20,6 +20,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
request.Host,
request.BaseUrl,
channels,
request.UserAgent,
request.AccessToken));
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)

View File

@@ -1,20 +1,19 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Serilog.Core;
namespace ErsatzTV.Application.Configuration;
public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSettings, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly LoggingLevelSwitch _loggingLevelSwitch;
private readonly LoggingLevelSwitches _loggingLevelSwitches;
public UpdateGeneralSettingsHandler(
LoggingLevelSwitch loggingLevelSwitch,
LoggingLevelSwitches loggingLevelSwitches,
IConfigElementRepository configElementRepository)
{
_loggingLevelSwitch = loggingLevelSwitch;
_loggingLevelSwitches = loggingLevelSwitches;
_configElementRepository = configElementRepository;
}
@@ -24,8 +23,17 @@ public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSetting
private async Task<Unit> ApplyUpdate(GeneralSettingsViewModel generalSettings)
{
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.MinimumLogLevel);
_loggingLevelSwitch.MinimumLevel = generalSettings.MinimumLogLevel;
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.DefaultMinimumLogLevel);
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = generalSettings.DefaultMinimumLogLevel;
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevelScanning, generalSettings.ScanningMinimumLogLevel);
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = generalSettings.ScanningMinimumLogLevel;
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevelScheduling, generalSettings.SchedulingMinimumLogLevel);
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = generalSettings.SchedulingMinimumLogLevel;
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevelStreaming, generalSettings.StreamingMinimumLogLevel);
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = generalSettings.StreamingMinimumLogLevel;
return Unit.Default;
}

View File

@@ -4,5 +4,8 @@ namespace ErsatzTV.Application.Configuration;
public class GeneralSettingsViewModel
{
public LogEventLevel MinimumLogLevel { get; set; }
public LogEventLevel DefaultMinimumLogLevel { get; set; }
public LogEventLevel ScanningMinimumLogLevel { get; set; }
public LogEventLevel SchedulingMinimumLogLevel { get; set; }
public LogEventLevel StreamingMinimumLogLevel { get; set; }
}

View File

@@ -13,12 +13,24 @@ public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, Gen
public async Task<GeneralSettingsViewModel> Handle(GetGeneralSettings request, CancellationToken cancellationToken)
{
Option<LogEventLevel> maybeLogLevel =
Option<LogEventLevel> maybeDefaultLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
Option<LogEventLevel> maybeScanningLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScanning);
Option<LogEventLevel> maybeSchedulingLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScheduling);
Option<LogEventLevel> maybeStreamingLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelStreaming);
return new GeneralSettingsViewModel
{
MinimumLogLevel = await maybeLogLevel.IfNoneAsync(LogEventLevel.Information)
DefaultMinimumLogLevel = await maybeDefaultLevel.IfNoneAsync(LogEventLevel.Information),
ScanningMinimumLogLevel = await maybeScanningLevel.IfNoneAsync(LogEventLevel.Information),
SchedulingMinimumLogLevel = await maybeSchedulingLevel.IfNoneAsync(LogEventLevel.Information),
StreamingMinimumLogLevel = await maybeStreamingLevel.IfNoneAsync(LogEventLevel.Information),
};
}
}

View File

@@ -92,7 +92,7 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
toAdd,
toRemove,
toUpdate);
if (ids.Any())
if (ids.Count != 0)
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
@@ -12,15 +12,16 @@
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.6.4" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.7.30">
<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.8.14">
<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="2.0.0" />
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="3.0.0" />
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -35,6 +35,8 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=programschedules_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=scheduling_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=scheduling_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Ccommands/@EntryIndexedValue">True</s:Boolean>

View File

@@ -1,5 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles;
@@ -8,9 +10,13 @@ public class
CopyFFmpegProfileHandler : IRequestHandler<CopyFFmpegProfile, Either<BaseError, FFmpegProfileViewModel>>
{
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
private readonly ISearchTargets _searchTargets;
public CopyFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository) =>
public CopyFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository, ISearchTargets searchTargets)
{
_ffmpegProfileRepository = ffmpegProfileRepository;
_searchTargets = searchTargets;
}
public Task<Either<BaseError, FFmpegProfileViewModel>> Handle(
CopyFFmpegProfile request,
@@ -19,9 +25,12 @@ public class
.MapT(PerformCopy)
.Bind(v => v.ToEitherAsync());
private Task<FFmpegProfileViewModel> PerformCopy(CopyFFmpegProfile request) =>
_ffmpegProfileRepository.Copy(request.FFmpegProfileId, request.Name)
.Map(ProjectToViewModel);
private async Task<FFmpegProfileViewModel> PerformCopy(CopyFFmpegProfile request)
{
FFmpegProfile copy = await _ffmpegProfileRepository.Copy(request.FFmpegProfileId, request.Name);
_searchTargets.SearchTargetsChanged();
return ProjectToViewModel(copy);
}
private static Task<Validation<BaseError, CopyFFmpegProfile>> Validate(CopyFFmpegProfile request) =>
ValidateName(request).AsTask().MapT(_ => request);

View File

@@ -12,6 +12,7 @@ public record CreateFFmpegProfile(
string VaapiDevice,
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FFmpegProfileVideoFormat VideoFormat,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
@@ -19,7 +20,7 @@ public record CreateFFmpegProfile(
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
NormalizeLoudnessMode NormalizeLoudnessMode,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -10,9 +11,13 @@ public class CreateFFmpegProfileHandler :
IRequestHandler<CreateFFmpegProfile, Either<BaseError, CreateFFmpegProfileResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public CreateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public CreateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, CreateFFmpegProfileResult>> Handle(
CreateFFmpegProfile request,
@@ -23,12 +28,13 @@ public class CreateFFmpegProfileHandler :
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
}
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
private async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
TvContext dbContext,
FFmpegProfile ffmpegProfile)
{
await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
}
@@ -46,6 +52,7 @@ public class CreateFFmpegProfileHandler :
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
VideoFormat = request.VideoFormat,
BitDepth = request.BitDepth,
VideoBitrate = request.VideoBitrate,
@@ -53,7 +60,7 @@ public class CreateFFmpegProfileHandler :
AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudness = request.NormalizeLoudness,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -9,23 +10,28 @@ namespace ErsatzTV.Application.FFmpegProfiles;
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteFFmpegProfile request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
return await validation.Apply(p => DoDeletion(dbContext, p));
}
private static async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
private async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
{
dbContext.FFmpegProfiles.Remove(ffmpegProfile);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}

View File

@@ -13,6 +13,7 @@ public record UpdateFFmpegProfile(
string VaapiDevice,
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FFmpegProfileVideoFormat VideoFormat,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
@@ -20,7 +21,7 @@ public record UpdateFFmpegProfile(
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
NormalizeLoudnessMode NormalizeLoudnessMode,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -10,9 +11,13 @@ public class
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
UpdateFFmpegProfile request,
@@ -23,7 +28,7 @@ public class
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
}
private static async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
TvContext dbContext,
FFmpegProfile p,
UpdateFFmpegProfile update)
@@ -35,6 +40,7 @@ public class
p.VaapiDevice = update.VaapiDevice;
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.VideoFormat = update.VideoFormat;
// mpeg2video only supports 8-bit content
@@ -47,12 +53,15 @@ public class
p.AudioFormat = update.AudioFormat;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudness = update.NormalizeLoudness;
p.NormalizeLoudnessMode = update.NormalizeLoudnessMode;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeFramerate = update.NormalizeFramerate;
p.DeinterlaceVideo = update.DeinterlaceVideo;
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return new UpdateFFmpegProfileResult(p.Id);
}

View File

@@ -13,6 +13,7 @@ public record FFmpegProfileViewModel(
string VaapiDevice,
int? QsvExtraHardwareFrames,
ResolutionViewModel Resolution,
ScalingBehavior ScalingBehavior,
FFmpegProfileVideoFormat VideoFormat,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
@@ -20,7 +21,7 @@ public record FFmpegProfileViewModel(
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
NormalizeLoudnessMode NormalizeLoudnessMode,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,

View File

@@ -15,6 +15,7 @@ internal static class Mapper
profile.VaapiDevice,
profile.QsvExtraHardwareFrames,
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
profile.ScalingBehavior,
profile.VideoFormat,
profile.BitDepth,
profile.VideoBitrate,
@@ -22,7 +23,7 @@ internal static class Mapper
profile.AudioFormat,
profile.AudioBitrate,
profile.AudioBufferSize,
profile.NormalizeLoudness,
profile.NormalizeLoudnessMode,
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeFramerate,
@@ -51,7 +52,7 @@ internal static class Mapper
(int)ffmpegProfile.AudioFormat,
ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize,
ffmpegProfile.NormalizeLoudness,
(int)ffmpegProfile.NormalizeLoudnessMode,
ffmpegProfile.AudioChannels,
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles;
public record GetSupportedHardwareAccelerationKinds : IRequest<List<HardwareAccelerationKind>>;

View File

@@ -0,0 +1,79 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles;
public class
GetSupportedHardwareAccelerationKindsHandler : IRequestHandler<GetSupportedHardwareAccelerationKinds,
List<HardwareAccelerationKind>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
public GetSupportedHardwareAccelerationKindsHandler(
IDbContextFactory<TvContext> dbContextFactory,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory)
{
_dbContextFactory = dbContextFactory;
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
}
public async Task<List<HardwareAccelerationKind>> Handle(
GetSupportedHardwareAccelerationKinds request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await Validate(dbContext);
return await validation.Match(
GetHardwareAccelerationKinds,
_ => Task.FromResult(new List<HardwareAccelerationKind> { HardwareAccelerationKind.None }));
}
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(string ffmpegPath)
{
var result = new List<HardwareAccelerationKind> { HardwareAccelerationKind.None };
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc))
{
result.Add(HardwareAccelerationKind.Nvenc);
}
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Qsv))
{
result.Add(HardwareAccelerationKind.Qsv);
}
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Vaapi))
{
result.Add(HardwareAccelerationKind.Vaapi);
}
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.VideoToolbox))
{
result.Add(HardwareAccelerationKind.VideoToolbox);
}
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Amf))
{
result.Add(HardwareAccelerationKind.Amf);
}
return result;
}
private static async Task<Validation<BaseError, string>> Validate(TvContext dbContext) =>
await FFmpegPathMustExist(dbContext);
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
}

View File

@@ -0,0 +1,85 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Jellyfin;
public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinCollections>,
IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>>
{
public CallJellyfinCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
});
}
protected override async Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
SynchronizeJellyfinCollections request)
{
DateTime minDateTime = await dbContext.JellyfinMediaSources
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizeJellyfinCollections request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
SynchronizeJellyfinCollections request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-jellyfin-collections", request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture)
};
if (request.ForceScan)
{
arguments.Add("--force");
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
}
}

View File

@@ -0,0 +1,7 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) :
IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;

View File

@@ -94,7 +94,7 @@ public class
toAdd,
toRemove,
toUpdate);
if (ids.Any())
if (ids.Count != 0)
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();

View File

@@ -84,7 +84,16 @@ public abstract class CallLibraryScannerHandler<TRequest>
// because the compact json writer used by the scanner
// writes in UTC
LogEvent logEvent = LogEventReader.ReadFromString(s);
Log.Write(
ILogger log = Log.Logger;
if (logEvent.Properties.TryGetValue("SourceContext", out LogEventPropertyValue property))
{
log = log.ForContext(
Serilog.Core.Constants.SourceContextPropertyName,
property.ToString().Trim('"'));
}
log.Write(
new LogEvent(
logEvent.Timestamp.ToLocalTime(),
logEvent.Level,

View File

@@ -15,20 +15,51 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti
GetExternalCollections request,
CancellationToken cancellationToken)
{
List<LibraryViewModel> result = new();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<int> mediaSourceIds = await dbContext.EmbyMediaSources
result.AddRange(await GetEmbyExternalCollections(dbContext, cancellationToken));
result.AddRange(await GetJellyfinExternalCollections(dbContext, cancellationToken));
result.AddRange(await GetPlexExternalCollections(dbContext, cancellationToken));
return result;
}
private static async Task<IEnumerable<LibraryViewModel>> GetEmbyExternalCollections(
TvContext dbContext,
CancellationToken cancellationToken)
{
List<int> embyMediaSourceIds = await dbContext.EmbyMediaSources
.Filter(ems => ems.Libraries.Any(l => ((EmbyLibrary)l).ShouldSyncItems))
.Map(ems => ems.Id)
.ToListAsync(cancellationToken);
return mediaSourceIds.Map(
id => new LibraryViewModel(
"Emby",
0,
"Collections",
0,
id,
string.Empty))
.ToList();
return embyMediaSourceIds.Map(id => new LibraryViewModel("Emby", 0, "Collections", 0, id, string.Empty));
}
private static async Task<IEnumerable<LibraryViewModel>> GetJellyfinExternalCollections(
TvContext dbContext,
CancellationToken cancellationToken)
{
List<int> jellyfinMediaSourceIds = await dbContext.JellyfinMediaSources
.Filter(jms => jms.Libraries.Any(l => ((JellyfinLibrary)l).ShouldSyncItems))
.Map(jms => jms.Id)
.ToListAsync(cancellationToken);
return jellyfinMediaSourceIds.Map(
id => new LibraryViewModel("Jellyfin", 0, "Collections", 0, id, string.Empty));
}
private static async Task<IEnumerable<LibraryViewModel>> GetPlexExternalCollections(
TvContext dbContext,
CancellationToken cancellationToken)
{
List<int> plexMediaSourceIds = await dbContext.PlexMediaSources
.Filter(pms => pms.Libraries.Any(l => ((PlexLibrary)l).ShouldSyncItems))
.Map(pms => pms.Id)
.ToListAsync(cancellationToken);
return plexMediaSourceIds.Map(
id => new LibraryViewModel("Plex", 0, "Collections", 0, id, string.Empty));
}
}

View File

@@ -27,7 +27,7 @@ public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
return Task.CompletedTask;
}
bool hasActiveWorkers = _ffmpegSegmenterService.SessionWorkers.Any() || FFmpegProcess.ProcessCount > 0;
bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count >= 0 || FFmpegProcess.ProcessCount > 0;
if (request.ForceAggressive || !hasActiveWorkers)
{
_logger.LogDebug("Starting aggressive garbage collection");

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -10,25 +11,28 @@ public class CreateCollectionHandler :
IRequestHandler<CreateCollection, Either<BaseError, MediaCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public CreateCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public CreateCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, MediaCollectionViewModel>> Handle(
CreateCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<MediaCollectionViewModel> PersistCollection(
TvContext dbContext,
Collection collection)
private async Task<MediaCollectionViewModel> PersistCollection(TvContext dbContext, Collection collection)
{
await dbContext.Collections.AddAsync(collection);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return ProjectToViewModel(collection);
}

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -10,25 +11,30 @@ public class CreateMultiCollectionHandler :
IRequestHandler<CreateMultiCollection, Either<BaseError, MultiCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public CreateMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public CreateMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, MultiCollectionViewModel>> Handle(
CreateMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<MultiCollectionViewModel> PersistCollection(
private async Task<MultiCollectionViewModel> PersistCollection(
TvContext dbContext,
MultiCollection multiCollection)
{
await dbContext.MultiCollections.AddAsync(multiCollection);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
await dbContext.Entry(multiCollection)
.Collection(c => c.MultiCollectionItems)
.Query()

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -10,25 +11,30 @@ public class CreateSmartCollectionHandler :
IRequestHandler<CreateSmartCollection, Either<BaseError, SmartCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public CreateSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public CreateSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, SmartCollectionViewModel>> Handle(
CreateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<SmartCollectionViewModel> PersistCollection(
private async Task<SmartCollectionViewModel> PersistCollection(
TvContext dbContext,
SmartCollection smartCollection)
{
await dbContext.SmartCollections.AddAsync(smartCollection);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return ProjectToViewModel(smartCollection);
}

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -9,24 +10,29 @@ namespace ErsatzTV.Application.MediaCollections;
public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public DeleteCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await CollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, Collection collection)
private async Task<Unit> DoDeletion(TvContext dbContext, Collection collection)
{
dbContext.Collections.Remove(collection);
return dbContext.SaveChangesAsync().ToUnit();
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}
private static Task<Validation<BaseError, Collection>> CollectionMustExist(

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -9,24 +10,30 @@ namespace ErsatzTV.Application.MediaCollections;
public class DeleteMultiCollectionHandler : IRequestHandler<DeleteMultiCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public DeleteMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, MultiCollection> validation = await MultiCollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, MultiCollection multiCollection)
private async Task<Unit> DoDeletion(TvContext dbContext, MultiCollection multiCollection)
{
dbContext.MultiCollections.Remove(multiCollection);
return dbContext.SaveChangesAsync().ToUnit();
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(

View File

@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -9,24 +10,29 @@ namespace ErsatzTV.Application.MediaCollections;
public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public DeleteSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
private async Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
{
dbContext.SmartCollections.Remove(smartCollection);
return dbContext.SaveChangesAsync().ToUnit();
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(

View File

@@ -46,7 +46,7 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
if (itemsToRemove.Any() && await dbContext.SaveChangesAsync() > 0)
if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync() > 0)
{
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))

View File

@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -13,17 +14,20 @@ namespace ErsatzTV.Application.MediaCollections;
public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly ISearchTargets _searchTargets;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
@@ -32,7 +36,7 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, Collection c, UpdateCollection request)
@@ -52,6 +56,8 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}

View File

@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -13,17 +14,20 @@ namespace ErsatzTV.Application.MediaCollections;
public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly ISearchTargets _searchTargets;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateMultiCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
@@ -115,6 +119,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
_searchTargets.SearchTargetsChanged();
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection(
request.MultiCollectionId))

View File

@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -13,17 +14,20 @@ namespace ErsatzTV.Application.MediaCollections;
public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly ISearchTargets _searchTargets;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateSmartCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
@@ -32,7 +36,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, SmartCollection c, UpdateSmartCollection request)
@@ -42,6 +46,8 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
_searchTargets.SearchTargetsChanged();
// refresh all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
{

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using System.Globalization;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaItems;
@@ -8,13 +9,27 @@ internal static class Mapper
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
internal static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
new(season.Id, $"{ShowTitle(season)} ({SeasonDescription(season)})");
new(season.Id, $"{ShowTitle(season)} - {SeasonDescription(season)}");
internal static NamedMediaItemViewModel ProjectToViewModel(Artist artist) =>
new(artist.Id, artist.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => "???"));
private static string ShowTitle(Season season) =>
season.Show.ShowMetadata.HeadOrNone().Map(sm => sm.Title).IfNone("???");
private static string ShowTitle(Season season)
{
var title = "???";
var year = "???";
foreach (ShowMetadata show in season.Show.ShowMetadata.HeadOrNone())
{
title = show.Title;
foreach (int y in Optional(show.Year))
{
year = y.ToString(CultureInfo.InvariantCulture);
}
}
return $"{title} ({year})";
}
private static string SeasonDescription(Season season) =>
season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}";

View File

@@ -16,4 +16,5 @@ public record MediaItemInfo(
VideoScanKind VideoScanKind,
int Width,
int Height,
List<MediaItemInfoStream> Streams);
List<MediaItemInfoStream> Streams,
List<MediaItemInfoChapter> Chapters);

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaItems;
public record MediaItemInfoChapter(string Title, TimeSpan StartTime, TimeSpan EndTime);

View File

@@ -31,8 +31,12 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
.Include(i => (i as Movie).MovieMetadata)
.ThenInclude(mv => mv.Subtitles)
.Include(i => (i as Movie).MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(i => (i as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => (i as Episode).MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(i => (i as Episode).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => (i as Episode).EpisodeMetadata)
.ThenInclude(mv => mv.Subtitles)
@@ -71,6 +75,8 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
// include external subtitles from local libraries
allStreams.AddRange(subtitles.Filter(s => s.SubtitleKind is SubtitleKind.Sidecar).Map(ProjectToStream));
var allChapters = (version.Chapters ?? []).OrderBy(c => c.StartTime).Map(Project).ToList();
return new MediaItemInfo(
mediaItem.Id,
mediaItem.GetType().Name,
@@ -85,7 +91,8 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
version.VideoScanKind,
version.Width,
version.Height,
allStreams);
allStreams,
allChapters);
}
private static MediaItemInfoStream Project(MediaStream mediaStream) =>
@@ -129,4 +136,7 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
null,
string.IsNullOrWhiteSpace(subtitle.Path) ? null : Path.GetFileName(subtitle.Path),
null);
private static MediaItemInfoChapter Project(MediaChapter chapter) =>
new(chapter.Title, chapter.StartTime, chapter.EndTime);
}

View File

@@ -73,7 +73,7 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
.Filter(lp => lp.LibraryId == request.LibraryId)
.ToListAsync();
DateTime minDateTime = libraryPaths.Any()
DateTime minDateTime = libraryPaths.Count != 0
? libraryPaths.Min(lp => lp.LastScan ?? SystemTime.MinValueUtc)
: SystemTime.MaxValueUtc;

View File

@@ -46,13 +46,12 @@ internal static class Mapper
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languageCodes
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.Map(ci => ci.EnglishName)
.Distinct()
.ToList();
}

View File

@@ -22,12 +22,16 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
private readonly IEntityLocker _entityLocker;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IPlayoutBuilder _playoutBuilder;
private readonly IBlockPlayoutBuilder _blockPlayoutBuilder;
private readonly IExternalJsonPlayoutBuilder _externalJsonPlayoutBuilder;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public BuildPlayoutHandler(
IClient client,
IDbContextFactory<TvContext> dbContextFactory,
IPlayoutBuilder playoutBuilder,
IBlockPlayoutBuilder blockPlayoutBuilder,
IExternalJsonPlayoutBuilder externalJsonPlayoutBuilder,
IFFmpegSegmenterService ffmpegSegmenterService,
IEntityLocker entityLocker,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
@@ -35,6 +39,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
_client = client;
_dbContextFactory = dbContextFactory;
_playoutBuilder = playoutBuilder;
_blockPlayoutBuilder = blockPlayoutBuilder;
_externalJsonPlayoutBuilder = externalJsonPlayoutBuilder;
_ffmpegSegmenterService = ffmpegSegmenterService;
_entityLocker = entityLocker;
_workerChannel = workerChannel;
@@ -59,7 +65,20 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{
_entityLocker.LockPlayout(playout.Id);
await _playoutBuilder.Build(playout, request.Mode, cancellationToken);
switch (playout.ProgramSchedulePlayoutType)
{
case ProgramSchedulePlayoutType.Block:
await _blockPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
break;
case ProgramSchedulePlayoutType.ExternalJson:
await _externalJsonPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
break;
case ProgramSchedulePlayoutType.None:
case ProgramSchedulePlayoutType.Flood:
default:
await _playoutBuilder.Build(playout, request.Mode, cancellationToken);
break;
}
// let any active segmenter processes know that the playout has been modified
// and therefore the segmenter may need to seek into the next item instead of
@@ -70,8 +89,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
}
_entityLocker.UnlockPlayout(playout.Id);
Option<string> maybeChannelNumber = await dbContext.Connection
.QuerySingleOrDefaultAsync<string>(
@"select C.Number from Channel C
@@ -83,7 +100,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
foreach (string channelNumber in maybeChannelNumber)
{
string fileName = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channelNumber}.xml");
if (hasChanges || !File.Exists(fileName))
if (hasChanges || !File.Exists(fileName) || playout.ProgramSchedulePlayoutType is ProgramSchedulePlayoutType.ExternalJson)
{
await _workerChannel.WriteAsync(new RefreshChannelData(channelNumber), cancellationToken);
}
@@ -99,10 +116,16 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
}
catch (Exception ex)
{
DebugBreak.Break();
_client.Notify(ex);
return BaseError.New(
$"Unexpected error building playout for channel {playout.Channel.Name}: {ex.Message}");
}
finally
{
_entityLocker.UnlockPlayout(playout.Id);
}
return Unit.Default;
}
@@ -113,7 +136,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
private static Validation<BaseError, Playout> DiscardAttemptsMustBeValid(Playout playout)
{
foreach (ProgramScheduleItemDuration item in
playout.ProgramSchedule.Items.OfType<ProgramScheduleItemDuration>())
playout.ProgramSchedule?.Items.OfType<ProgramScheduleItemDuration>() ?? [])
{
item.DiscardToFillAttempts = item.PlaybackOrder switch
{
@@ -131,6 +154,14 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
dbContext.Playouts
.Include(p => p.Channel)
.Include(p => p.Items)
.Include(p => p.PlayoutHistory)
.Include(p => p.Templates)
.ThenInclude(t => t.Template)
.ThenInclude(t => t.Items)
.ThenInclude(i => i.Block)
.ThenInclude(b => b.Items)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)

View File

@@ -0,0 +1,68 @@
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
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 CreateBlockPlayoutHandler(
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateBlockPlayout, Either<BaseError, CreatePlayoutResponse>>
{
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
CreateBlockPlayout 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 static async Task<Validation<BaseError, Playout>> Validate(
TvContext dbContext,
CreateBlockPlayout request) =>
(await ValidateChannel(dbContext, request), ValidatePlayoutType(request))
.Apply(
(channel, playoutType) => new Playout
{
ChannelId = channel.Id,
ProgramSchedulePlayoutType = playoutType,
Seed = new Random().Next()
});
private static Task<Validation<BaseError, Channel>> ValidateChannel(
TvContext dbContext,
CreateBlockPlayout createBlockPlayout) =>
dbContext.Channels
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == createBlockPlayout.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 static Validation<BaseError, ProgramSchedulePlayoutType> ValidatePlayoutType(
CreateBlockPlayout createBlockPlayout) =>
Optional(createBlockPlayout.ProgramSchedulePlayoutType)
.Filter(playoutType => playoutType == ProgramSchedulePlayoutType.Block)
.ToValidation<BaseError>("[ProgramSchedulePlayoutType] must be Block");
}

View File

@@ -0,0 +1,91 @@
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 CreateExternalJsonPlayoutHandler
: IRequestHandler<CreateExternalJsonPlayout, Either<BaseError, CreatePlayoutResponse>>
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateExternalJsonPlayoutHandler(
ILocalFileSystem localFileSystem,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
{
_localFileSystem = localFileSystem;
_channel = channel;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
CreateExternalJsonPlayout 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,
CreateExternalJsonPlayout request) =>
(await ValidateChannel(dbContext, request), ValidateExternalJsonFile(request), ValidatePlayoutType(request))
.Apply(
(channel, externalJsonFile, playoutType) => new Playout
{
ChannelId = channel.Id,
ExternalJsonFile = externalJsonFile,
ProgramSchedulePlayoutType = playoutType
});
private static Task<Validation<BaseError, Channel>> ValidateChannel(
TvContext dbContext,
CreateExternalJsonPlayout createExternalJsonPlayout) =>
dbContext.Channels
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == createExternalJsonPlayout.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> ValidateExternalJsonFile(CreateExternalJsonPlayout request)
{
if (!_localFileSystem.FileExists(request.ExternalJsonFile))
{
return BaseError.New("External Json File does not exist!");
}
return request.ExternalJsonFile;
}
private static Validation<BaseError, ProgramSchedulePlayoutType> ValidatePlayoutType(
CreateExternalJsonPlayout createExternalJsonPlayout) =>
Optional(createExternalJsonPlayout.ProgramSchedulePlayoutType)
.Filter(playoutType => playoutType == ProgramSchedulePlayoutType.ExternalJson)
.ToValidation<BaseError>("[ProgramSchedulePlayoutType] must be ExternalJson");
}

View File

@@ -10,12 +10,12 @@ using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Playouts;
public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseError, CreatePlayoutResponse>>
public class CreateFloodPlayoutHandler : IRequestHandler<CreateFloodPlayout, Either<BaseError, CreatePlayoutResponse>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreatePlayoutHandler(
public CreateFloodPlayoutHandler(
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
{
@@ -24,12 +24,12 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
}
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
CreatePlayout request,
CreateFloodPlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, playout => PersistPlayout(dbContext, playout));
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
}
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
@@ -41,7 +41,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
return new CreatePlayoutResponse(playout.Id);
}
private static async Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, CreatePlayout request) =>
private static async Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, CreateFloodPlayout request) =>
(await ValidateChannel(dbContext, request), await ValidateProgramSchedule(dbContext, request),
ValidatePlayoutType(request))
.Apply(
@@ -54,10 +54,10 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
private static Task<Validation<BaseError, Channel>> ValidateChannel(
TvContext dbContext,
CreatePlayout createPlayout) =>
CreateFloodPlayout createFloodPlayout) =>
dbContext.Channels
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == createPlayout.ChannelId)
.SelectOneAsync(c => c.Id, c => c.Id == createFloodPlayout.ChannelId)
.Map(o => o.ToValidation<BaseError>("Channel does not exist"))
.BindT(ChannelMustNotHavePlayouts);
@@ -69,22 +69,22 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
private static Task<Validation<BaseError, ProgramSchedule>> ValidateProgramSchedule(
TvContext dbContext,
CreatePlayout createPlayout) =>
CreateFloodPlayout createFloodPlayout) =>
dbContext.ProgramSchedules
.Include(ps => ps.Items)
.SelectOneAsync(ps => ps.Id, ps => ps.Id == createPlayout.ProgramScheduleId)
.SelectOneAsync(ps => ps.Id, ps => ps.Id == createFloodPlayout.ProgramScheduleId)
.Map(o => o.ToValidation<BaseError>("Program schedule does not exist"))
.BindT(ProgramScheduleMustHaveItems);
private static Validation<BaseError, ProgramSchedule> ProgramScheduleMustHaveItems(
ProgramSchedule programSchedule) =>
Optional(programSchedule)
.Filter(ps => ps.Items.Any())
.Filter(ps => ps.Items.Count != 0)
.ToValidation<BaseError>("Program schedule must have items");
private static Validation<BaseError, ProgramSchedulePlayoutType> ValidatePlayoutType(
CreatePlayout createPlayout) =>
Optional(createPlayout.ProgramSchedulePlayoutType)
CreateFloodPlayout createFloodPlayout) =>
Optional(createFloodPlayout.ProgramSchedulePlayoutType)
.Filter(playoutType => playoutType != ProgramSchedulePlayoutType.None)
.ToValidation<BaseError>("[ProgramSchedulePlayoutType] must not be None");
}

View File

@@ -3,7 +3,14 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Playouts;
public record CreatePlayout(
int ChannelId,
int ProgramScheduleId,
ProgramSchedulePlayoutType ProgramSchedulePlayoutType) : IRequest<Either<BaseError, CreatePlayoutResponse>>;
public record CreatePlayout(int ChannelId, ProgramSchedulePlayoutType ProgramSchedulePlayoutType)
: IRequest<Either<BaseError, CreatePlayoutResponse>>;
public record CreateFloodPlayout(int ChannelId, int ProgramScheduleId)
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Flood);
public record CreateBlockPlayout(int ChannelId)
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Block);
public record CreateExternalJsonPlayout(int ChannelId, string ExternalJsonFile)
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.ExternalJson);

View File

@@ -25,9 +25,7 @@ public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseEr
_localFileSystem = localFileSystem;
}
public async Task<Either<BaseError, Unit>> Handle(
DeletePlayout request,
CancellationToken cancellationToken)
public async Task<Either<BaseError, Unit>> Handle(DeletePlayout request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Playouts;
public record UpdateExternalJsonPlayout(int PlayoutId, string ExternalJsonFile)
: IRequest<Either<BaseError, PlayoutNameViewModel>>;

View File

@@ -0,0 +1,65 @@
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 UpdateExternalJsonPlayoutHandler : IRequestHandler<UpdateExternalJsonPlayout, Either<BaseError, PlayoutNameViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdateExternalJsonPlayoutHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
_dbContextFactory = dbContextFactory;
_workerChannel = workerChannel;
}
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
UpdateExternalJsonPlayout 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,
UpdateExternalJsonPlayout request,
Playout playout)
{
playout.ExternalJsonFile = request.ExternalJsonFile;
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.ProgramSchedule?.Name ?? string.Empty,
playout.ExternalJsonFile,
Optional(playout.DailyRebuildTime));
}
private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, UpdateExternalJsonPlayout request) =>
PlayoutMustExist(dbContext, request);
private static Task<Validation<BaseError, Playout>> PlayoutMustExist(
TvContext dbContext,
UpdateExternalJsonPlayout 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."));
}

View File

@@ -17,7 +17,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
UpdatePlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
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));
}
@@ -38,9 +38,11 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
return new PlayoutNameViewModel(
playout.Id,
playout.ProgramSchedulePlayoutType,
playout.Channel.Name,
playout.Channel.Number,
playout.ProgramSchedule.Name,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ExternalJsonFile,
Optional(playout.DailyRebuildTime));
}

View File

@@ -22,7 +22,7 @@ internal static class Mapper
programScheduleAlternate.DaysOfMonth,
programScheduleAlternate.MonthsOfYear);
private static string GetDisplayTitle(PlayoutItem playoutItem)
internal static string GetDisplayTitle(PlayoutItem playoutItem)
{
switch (playoutItem.MediaItem)
{
@@ -80,7 +80,7 @@ internal static class Mapper
}
}
private static string GetDisplayDuration(TimeSpan duration) =>
internal static string GetDisplayDuration(TimeSpan duration) =>
string.Format(
CultureInfo.InvariantCulture,
duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",

View File

@@ -1,8 +1,12 @@
namespace ErsatzTV.Application.Playouts;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Playouts;
public record PlayoutNameViewModel(
int PlayoutId,
ProgramSchedulePlayoutType PlayoutType,
string ChannelName,
string ChannelNumber,
string ScheduleName,
string ExternalJsonFile,
Option<TimeSpan> DailyRebuildTime);

View File

@@ -16,13 +16,17 @@ public class GetAllPlayoutsHandler : IRequestHandler<GetAllPlayouts, List<Playou
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Filter(p => p.Channel != null && p.ProgramSchedule != null)
.AsNoTracking()
.Include(p => p.ProgramSchedule)
.Filter(p => p.Channel != null)
.Map(
p => new PlayoutNameViewModel(
p.Id,
p.ProgramSchedulePlayoutType,
p.Channel.Name,
p.Channel.Number,
p.ProgramSchedule.Name,
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
p.ExternalJsonFile,
Optional(p.DailyRebuildTime)))
.ToListAsync(cancellationToken);
}

View File

@@ -0,0 +1,83 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Plex;
public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<SynchronizePlexCollections>,
IRequestHandler<SynchronizePlexCollections, Either<BaseError, Unit>>
{
public CallPlexCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizePlexCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
});
}
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizePlexCollections request)
{
DateTime minDateTime = await dbContext.PlexMediaSources
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexMediaSourceId)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizePlexCollections request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
SynchronizePlexCollections request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-plex-collections", request.PlexMediaSourceId.ToString(CultureInfo.InvariantCulture)
};
if (request.ForceScan)
{
arguments.Add("--force");
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
}
}

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex;
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;

View File

@@ -3,4 +3,4 @@
namespace ErsatzTV.Application.Plex;
public record SynchronizePlexLibraries(int PlexMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IPlexBackgroundServiceRequest;
IScannerBackgroundServiceRequest;

View File

@@ -88,7 +88,7 @@ public class
connectionParameters.PlexMediaSource.Id,
toAdd,
toRemove);
if (ids.Any())
if (ids.Count != 0)
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();

View File

@@ -15,7 +15,7 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle
{
private const string LocalhostUri = "http://localhost:32400";
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
@@ -28,7 +28,7 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle
IPlexTvApiClient plexTvApiClient,
IPlexServerApiClient plexServerApiClient,
IPlexSecretStore plexSecretStore,
ChannelWriter<IPlexBackgroundServiceRequest> channel,
ChannelWriter<IScannerBackgroundServiceRequest> channel,
IEntityLocker entityLocker,
ILogger<SynchronizePlexMediaSourcesHandler> logger)
{

View File

@@ -14,6 +14,7 @@ public record AddProgramScheduleItem(
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
FillWithGroupMode FillWithGroupMode,
int? MultipleCount,
TimeSpan? PlayoutDuration,
TailMode TailMode,

View File

@@ -92,7 +92,7 @@ public class
return (result1, result2).Apply((_, _) => request.Name);
}
private static void DetachEntity<T>(DbContext db, T entity) where T : class
private static void DetachEntity<T>(TvContext db, T entity) where T : class
{
db.Entry(entity).State = EntityState.Detached;
if (entity.GetType().GetProperty("Id") is not null)

View File

@@ -12,6 +12,7 @@ public interface IProgramScheduleItemRequest
int? MediaItemId { get; }
PlayoutMode PlayoutMode { get; }
PlaybackOrder PlaybackOrder { get; }
FillWithGroupMode FillWithGroupMode { get; }
int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; }
TailMode TailMode { get; }

View File

@@ -183,6 +183,7 @@ public abstract class ProgramScheduleItemCommandBase
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
FillWithGroupMode = FillWithGroupMode.None,
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
PreRollFillerId = item.PreRollFillerId,
@@ -207,6 +208,7 @@ public abstract class ProgramScheduleItemCommandBase
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
FillWithGroupMode = FillWithGroupMode.None,
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
PreRollFillerId = item.PreRollFillerId,
@@ -231,6 +233,7 @@ public abstract class ProgramScheduleItemCommandBase
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
FillWithGroupMode = item.FillWithGroupMode,
Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
@@ -256,6 +259,7 @@ public abstract class ProgramScheduleItemCommandBase
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
FillWithGroupMode = item.FillWithGroupMode,
PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(),
TailMode = item.TailMode,
DiscardToFillAttempts = FixDiscardToFillAttempts(

View File

@@ -14,6 +14,7 @@ public record ReplaceProgramScheduleItem(
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
FillWithGroupMode FillWithGroupMode,
int? MultipleCount,
TimeSpan? PlayoutDuration,
TailMode TailMode,

View File

@@ -29,7 +29,7 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => PersistItems(dbContext, request, ps));
return await validation.Apply(ps => PersistItems(dbContext, request, ps));
}
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(

View File

@@ -40,6 +40,7 @@ internal static class Mapper
_ => null
},
duration.PlaybackOrder,
duration.FillWithGroupMode,
duration.PlayoutDuration,
duration.TailMode,
duration.DiscardToFillAttempts,
@@ -91,6 +92,7 @@ internal static class Mapper
_ => null
},
flood.PlaybackOrder,
flood.FillWithGroupMode,
flood.CustomTitle,
flood.GuideMode,
flood.PreRollFiller != null
@@ -139,6 +141,7 @@ internal static class Mapper
_ => null
},
multiple.PlaybackOrder,
multiple.FillWithGroupMode,
multiple.Count,
multiple.CustomTitle,
multiple.GuideMode,
@@ -188,6 +191,7 @@ internal static class Mapper
_ => null
},
one.PlaybackOrder,
one.FillWithGroupMode,
one.CustomTitle,
one.GuideMode,
one.PreRollFiller != null

View File

@@ -19,6 +19,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
FillWithGroupMode fillWithGroupMode,
TimeSpan playoutDuration,
TailMode tailMode,
int discardToFillAttempts,
@@ -45,6 +46,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
smartCollection,
mediaItem,
playbackOrder,
fillWithGroupMode,
customTitle,
guideMode,
preRollFiller,

View File

@@ -19,6 +19,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
FillWithGroupMode fillWithGroupMode,
string customTitle,
GuideMode guideMode,
FillerPresetViewModel preRollFiller,
@@ -42,6 +43,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
smartCollection,
mediaItem,
playbackOrder,
fillWithGroupMode,
customTitle,
guideMode,
preRollFiller,

View File

@@ -19,6 +19,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
FillWithGroupMode fillWithGroupMode,
int count,
string customTitle,
GuideMode guideMode,
@@ -43,6 +44,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
smartCollection,
mediaItem,
playbackOrder,
fillWithGroupMode,
customTitle,
guideMode,
preRollFiller,

View File

@@ -19,6 +19,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
FillWithGroupMode fillWithGroupMode,
string customTitle,
GuideMode guideMode,
FillerPresetViewModel preRollFiller,
@@ -42,6 +43,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
smartCollection,
mediaItem,
playbackOrder,
fillWithGroupMode,
customTitle,
guideMode,
preRollFiller,

View File

@@ -18,6 +18,7 @@ public abstract record ProgramScheduleItemViewModel(
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
FillWithGroupMode FillWithGroupMode,
string CustomTitle,
GuideMode GuideMode,
FillerPresetViewModel PreRollFiller,

View File

@@ -56,7 +56,7 @@ public class GetProgramScheduleItemsHandler :
.Map(psi => EnforceProperties(maybeProgramSchedule, psi)).ToList());
}
// shuffled schedule items supports a limited set of properly values
// shuffled schedule items supports a limited set of property values
private static ProgramScheduleItemViewModel EnforceProperties(
Option<ProgramSchedule> maybeProgramSchedule,
ProgramScheduleItemViewModel item)

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Scheduling;
public record BlockGroupViewModel(int Id, string Name, int BlockCount);

View File

@@ -0,0 +1,15 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Scheduling;
public record BlockItemViewModel(
int Id,
int Index,
ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection,
MultiCollectionViewModel MultiCollection,
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder);

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Application.Scheduling;
public record BlockViewModel(int Id, string Name, int Minutes, BlockStopScheduling StopScheduling);

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
public record CreateBlock(int BlockGroupId, string Name) : IRequest<Either<BaseError, BlockViewModel>>;

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
public record CreateBlockGroup(string Name) : IRequest<Either<BaseError, BlockGroupViewModel>>;

View File

@@ -0,0 +1,31 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class CreateBlockGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateBlockGroup, Either<BaseError, BlockGroupViewModel>>
{
public async Task<Either<BaseError, BlockGroupViewModel>> Handle(CreateBlockGroup request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, BlockGroup> validation = await Validate(request);
return await validation.Apply(profile => PersistBlockGroup(dbContext, profile));
}
private static async Task<BlockGroupViewModel> PersistBlockGroup(TvContext dbContext, BlockGroup blockGroup)
{
await dbContext.BlockGroups.AddAsync(blockGroup);
await dbContext.SaveChangesAsync();
return Mapper.ProjectToViewModel(blockGroup);
}
private static Task<Validation<BaseError, BlockGroup>> Validate(CreateBlockGroup request) =>
Task.FromResult(ValidateName(request).Map(name => new BlockGroup { Name = name, Blocks = [] }));
private static Validation<BaseError, string> ValidateName(CreateBlockGroup createBlockGroup) =>
createBlockGroup.NotEmpty(x => x.Name)
.Bind(_ => createBlockGroup.NotLongerThan(50)(x => x.Name));
}

View File

@@ -0,0 +1,40 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class CreateBlockHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateBlock, Either<BaseError, BlockViewModel>>
{
public async Task<Either<BaseError, BlockViewModel>> Handle(
CreateBlock request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Block> validation = await Validate(request);
return await validation.Apply(profile => PersistBlock(dbContext, profile));
}
private static async Task<BlockViewModel> PersistBlock(TvContext dbContext, Block block)
{
await dbContext.Blocks.AddAsync(block);
await dbContext.SaveChangesAsync();
return Mapper.ProjectToViewModel(block);
}
private static Task<Validation<BaseError, Block>> Validate(CreateBlock request) =>
Task.FromResult(
ValidateName(request).Map(
name => new Block
{
BlockGroupId = request.BlockGroupId,
Name = name,
Minutes = 30
}));
private static Validation<BaseError, string> ValidateName(CreateBlock createBlock) =>
createBlock.NotEmpty(x => x.Name)
.Bind(_ => createBlock.NotLongerThan(50)(x => x.Name));
}

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
public record CreateTemplate(int TemplateGroupId, string Name) : IRequest<Either<BaseError, TemplateViewModel>>;

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
public record CreateTemplateGroup(string Name) : IRequest<Either<BaseError, TemplateGroupViewModel>>;

View File

@@ -0,0 +1,35 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class CreateTemplateGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateTemplateGroup, Either<BaseError, TemplateGroupViewModel>>
{
public async Task<Either<BaseError, TemplateGroupViewModel>> Handle(
CreateTemplateGroup request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, TemplateGroup> validation = await Validate(request);
return await validation.Apply(profile => PersistTemplateGroup(dbContext, profile));
}
private static async Task<TemplateGroupViewModel> PersistTemplateGroup(
TvContext dbContext,
TemplateGroup templateGroup)
{
await dbContext.TemplateGroups.AddAsync(templateGroup);
await dbContext.SaveChangesAsync();
return Mapper.ProjectToViewModel(templateGroup);
}
private static Task<Validation<BaseError, TemplateGroup>> Validate(CreateTemplateGroup request) =>
Task.FromResult(ValidateName(request).Map(name => new TemplateGroup { Name = name, Templates = [] }));
private static Validation<BaseError, string> ValidateName(CreateTemplateGroup createTemplateGroup) =>
createTemplateGroup.NotEmpty(x => x.Name)
.Bind(_ => createTemplateGroup.NotLongerThan(50)(x => x.Name));
}

View File

@@ -0,0 +1,39 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class CreateTemplateHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateTemplate, Either<BaseError, TemplateViewModel>>
{
public async Task<Either<BaseError, TemplateViewModel>> Handle(
CreateTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Template> validation = await Validate(request);
return await validation.Apply(profile => PersistTemplate(dbContext, profile));
}
private static async Task<TemplateViewModel> PersistTemplate(TvContext dbContext, Template template)
{
await dbContext.Templates.AddAsync(template);
await dbContext.SaveChangesAsync();
return Mapper.ProjectToViewModel(template);
}
private static Task<Validation<BaseError, Template>> Validate(CreateTemplate request) =>
Task.FromResult(
ValidateName(request).Map(
name => new Template
{
TemplateGroupId = request.TemplateGroupId,
Name = name
}));
private static Validation<BaseError, string> ValidateName(CreateTemplate createTemplate) =>
createTemplate.NotEmpty(x => x.Name)
.Bind(_ => createTemplate.NotLongerThan(50)(x => x.Name));
}

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
public record DeleteBlock(int BlockId) : IRequest<Option<BaseError>>;

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
public record DeleteBlockGroup(int BlockGroupId) : IRequest<Option<BaseError>>;

View File

@@ -0,0 +1,29 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class DeleteBlockGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<DeleteBlockGroup, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(DeleteBlockGroup request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<BlockGroup> maybeBlockGroup = await dbContext.BlockGroups
.SelectOneAsync(p => p.Id, p => p.Id == request.BlockGroupId);
foreach (BlockGroup blockGroup in maybeBlockGroup)
{
dbContext.BlockGroups.Remove(blockGroup);
await dbContext.SaveChangesAsync(cancellationToken);
}
return maybeBlockGroup.Match(
_ => Option<BaseError>.None,
() => BaseError.New($"BlockGroup {request.BlockGroupId} does not exist."));
}
}

View File

@@ -0,0 +1,29 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class DeleteBlockHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<DeleteBlock, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(DeleteBlock request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Block> maybeBlock = await dbContext.Blocks
.SelectOneAsync(p => p.Id, p => p.Id == request.BlockId);
foreach (Block block in maybeBlock)
{
dbContext.Blocks.Remove(block);
await dbContext.SaveChangesAsync(cancellationToken);
}
return maybeBlock.Match(
_ => Option<BaseError>.None,
() => BaseError.New($"Block {request.BlockId} does not exist."));
}
}

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