Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ca72baa00 | ||
|
|
6b953ab5ca | ||
|
|
272f528f7a | ||
|
|
07c1156a63 | ||
|
|
eadacc7f8c | ||
|
|
380070731a | ||
|
|
7720e6ba39 | ||
|
|
8a1cf72209 | ||
|
|
b9759c983c | ||
|
|
9462156148 | ||
|
|
1c07df5bc3 | ||
|
|
a6198892f0 | ||
|
|
02a91c4e14 | ||
|
|
b62a76d339 | ||
|
|
d9f2f51aee | ||
|
|
8e77330781 | ||
|
|
66c28e9b5f | ||
|
|
51ec84c94a | ||
|
|
a072e4357e | ||
|
|
605c57bef3 | ||
|
|
4e2310d008 | ||
|
|
61a99c250a | ||
|
|
bbddd50f00 | ||
|
|
53f281ce32 | ||
|
|
e06ee54070 | ||
|
|
af23c6d541 | ||
|
|
988ed8db04 | ||
|
|
31c18162e1 | ||
|
|
0318e71745 | ||
|
|
1e7f9a5709 | ||
|
|
330195d5e3 | ||
|
|
5d081ceeff | ||
|
|
6d32dac51b | ||
|
|
4f02bedf69 | ||
|
|
d71443ef60 | ||
|
|
d5608ac75f | ||
|
|
a6b01cbe28 | ||
|
|
d0af507bef | ||
|
|
f626954eb7 | ||
|
|
62e140ec98 | ||
|
|
93bb7a0531 | ||
|
|
f31a48c429 | ||
|
|
0841bc400b | ||
|
|
8cc0d30c0e | ||
|
|
4b18ee6b66 | ||
|
|
558e2ce333 | ||
|
|
c9e6e601c2 | ||
|
|
d28d0a9805 | ||
|
|
ac75a67709 | ||
|
|
5e463758da | ||
|
|
2cb0d12701 | ||
|
|
44ec0f8a0f | ||
|
|
b149f7f2a3 | ||
|
|
771bfba01c | ||
|
|
820c2a5ccc | ||
|
|
91c4e8f575 | ||
|
|
a04adf45c0 | ||
|
|
8cbc3b083a | ||
|
|
1cac210765 | ||
|
|
6f9952924b | ||
|
|
1bf5b9567b | ||
|
|
a9f2037648 | ||
|
|
03c5b7e664 | ||
|
|
0e7ec6e3b9 | ||
|
|
3f247288d3 | ||
|
|
df0801f2c6 | ||
|
|
908125f8a9 | ||
|
|
942cf9e225 | ||
|
|
075f3fcac7 | ||
|
|
f4eadae8ff | ||
|
|
2dc5bf58a7 | ||
|
|
76a589b538 | ||
|
|
9f3db05c17 | ||
|
|
7ca2763109 | ||
|
|
14539d00d4 | ||
|
|
bd09f3dfdc | ||
|
|
0c22eefad2 | ||
|
|
2f06e5b6f7 | ||
|
|
f9db92d5e6 | ||
|
|
f2b6f5b919 | ||
|
|
c7fcaf8886 | ||
|
|
5a5c049835 | ||
|
|
a28f40e14b | ||
|
|
a2fc99229e | ||
|
|
036b6e63c7 | ||
|
|
fd7c3fc25a | ||
|
|
93dca6e0e0 | ||
|
|
e34368bf07 | ||
|
|
a4b485f562 | ||
|
|
6159b6a5b2 | ||
|
|
11100a788b | ||
|
|
b40ac9ef52 | ||
|
|
c055e59723 | ||
|
|
b52159e8db | ||
|
|
a728c5e31e | ||
|
|
61ce1bad08 | ||
|
|
ab2b926de0 | ||
|
|
3b955255ce | ||
|
|
16dd2c2d81 | ||
|
|
48f93b8af8 | ||
|
|
8b12ee459a | ||
|
|
b3d0b44e77 | ||
|
|
163fd0c1f3 | ||
|
|
b6ec16c6a7 | ||
|
|
aa3bd3b750 | ||
|
|
f04b7ead09 | ||
|
|
8921273900 | ||
|
|
0489741123 | ||
|
|
c3e882085b | ||
|
|
3ab9112c15 | ||
|
|
33b789db67 | ||
|
|
ed5206b855 | ||
|
|
baf7aa20d1 | ||
|
|
7bd0de99e1 | ||
|
|
96093c8cc8 | ||
|
|
8430a3048c | ||
|
|
06d9e59a7a | ||
|
|
9c434079d5 | ||
|
|
12c88a006d | ||
|
|
f0ca358c2b | ||
|
|
093abf7ad8 | ||
|
|
f768093df7 | ||
|
|
3830db60bf | ||
|
|
5984b38ce0 | ||
|
|
e0175fc4e5 | ||
|
|
4f104cff5b | ||
|
|
a2f678fe8e | ||
|
|
b3ac0c68a8 | ||
|
|
605d8a98ab | ||
|
|
00f40c2568 | ||
|
|
74733a8026 | ||
|
|
1df9104854 | ||
|
|
6c6ccfa94b | ||
|
|
e9d494c24e | ||
|
|
deff33c76c | ||
|
|
b5d1839d55 | ||
|
|
ab0f431c85 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.1.4",
|
||||
"version": "2025.2.0",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
[*]
|
||||
charset=utf-8
|
||||
end_of_line=lf
|
||||
trim_trailing_whitespace=true
|
||||
insert_final_newline=false
|
||||
insert_final_newline=true
|
||||
indent_style=space
|
||||
indent_size=4
|
||||
|
||||
@@ -15,7 +14,7 @@ csharp_style_expression_bodied_constructors=true:none
|
||||
csharp_style_expression_bodied_methods=true:none
|
||||
csharp_style_expression_bodied_properties=true:suggestion
|
||||
csharp_style_var_elsewhere=false:suggestion
|
||||
csharp_style_var_for_built_in_types=false:suggestion
|
||||
csharp_style_var_for_built_in_types=false:none
|
||||
csharp_style_var_when_type_is_apparent=true:suggestion
|
||||
dotnet_naming_rule.local_constants_rule.severity=warning
|
||||
dotnet_naming_rule.local_constants_rule.style=all_upper_style
|
||||
@@ -42,6 +41,8 @@ resharper_braces_for_for=required
|
||||
resharper_braces_for_foreach=required
|
||||
resharper_braces_for_ifelse=required
|
||||
resharper_braces_for_while=required
|
||||
resharper_csharp_arguments_literal=positional
|
||||
resharper_csharp_arguments_named=positional
|
||||
resharper_csharp_insert_final_newline=true
|
||||
resharper_csharp_max_attribute_length_for_same_line=0
|
||||
resharper_csharp_place_accessorholder_attribute_on_same_line=never
|
||||
@@ -66,7 +67,7 @@ resharper_built_in_type_reference_style_highlighting=hint
|
||||
resharper_redundant_base_qualifier_highlighting=warning
|
||||
resharper_suggest_var_or_type_built_in_types_highlighting=hint
|
||||
resharper_suggest_var_or_type_elsewhere_highlighting=hint
|
||||
resharper_suggest_var_or_type_simple_types_highlighting=hint
|
||||
resharper_suggest_var_or_type_simple_types_highlighting=none
|
||||
resharper_web_config_module_not_resolved_highlighting=warning
|
||||
resharper_web_config_type_not_resolved_highlighting=warning
|
||||
resharper_web_config_wrong_module_highlighting=warning
|
||||
@@ -84,7 +85,22 @@ tab_width=4
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
ij_json_array_wrapping = normal
|
||||
ij_json_keep_blank_lines_in_code = 0
|
||||
ij_json_keep_indents_on_empty_lines = false
|
||||
ij_json_keep_line_breaks = true
|
||||
ij_json_keep_trailing_comma = false
|
||||
ij_json_object_wrapping = normal
|
||||
ij_json_property_alignment = do_not_align
|
||||
ij_json_space_after_colon = true
|
||||
ij_json_space_after_comma = true
|
||||
ij_json_space_before_colon = false
|
||||
ij_json_space_before_comma = false
|
||||
ij_json_spaces_within_braces = true
|
||||
ij_json_spaces_within_brackets = true
|
||||
ij_json_wrap_long_lines = false
|
||||
|
||||
[*.cs]
|
||||
# disable CA1848: Use the LoggerMessage delegates`
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
|
||||
172
.github/workflows/artifacts.yml
vendored
172
.github/workflows/artifacts.yml
vendored
@@ -29,7 +29,6 @@ jobs:
|
||||
build_and_upload_mac:
|
||||
name: Mac Build & Upload
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -46,7 +45,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
@@ -126,22 +125,20 @@ jobs:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: |
|
||||
*${{ matrix.target }}.dmg
|
||||
assets: "*${{ matrix.target }}.dmg"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.dmg
|
||||
files: "${{ env.RELEASE_NAME }}.dmg"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
build_and_upload:
|
||||
name: Build & Upload
|
||||
|
||||
build_and_upload_linux:
|
||||
name: Build & Upload Linux
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -157,16 +154,13 @@ jobs:
|
||||
- os: ubuntu-24.04-arm
|
||||
kind: linux
|
||||
target: linux-arm64
|
||||
- os: windows-latest
|
||||
kind: windows
|
||||
target: win-x64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET Core
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
@@ -177,14 +171,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target }}"
|
||||
|
||||
- uses: suisei-cn/actions-download-file@v1.3.0
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
id: downloadffmpeg
|
||||
name: Download ffmpeg
|
||||
with:
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-56-gc2184b65d2-win64-gpl-7.1.zip"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -199,26 +185,7 @@ jobs:
|
||||
mkdir "$release_name"
|
||||
mv scanner/* "$release_name/"
|
||||
mv main/* "$release_name/"
|
||||
|
||||
# Build Windows launcher
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
|
||||
ls -l ErsatzTV-Windows/target/release
|
||||
mv ErsatzTV-Windows/target/release/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
fi
|
||||
|
||||
# Download ffmpeg
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
fi
|
||||
|
||||
# Pack files
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
else
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
fi
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
|
||||
# Delete output directory
|
||||
rm -r "$release_name"
|
||||
@@ -230,17 +197,128 @@ jobs:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: |
|
||||
*${{ matrix.target }}.zip
|
||||
*${{ matrix.target }}.tar.gz
|
||||
assets: "*${{ matrix.target }}.tar.gz"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.zip
|
||||
${{ env.RELEASE_NAME }}.tar.gz
|
||||
files: "${{ env.RELEASE_NAME }}.tar.gz"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
|
||||
build_dotnet_windows:
|
||||
name: Build dotnet for Windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "win-x64"
|
||||
|
||||
- name: Build dotnet projects
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Upload .NET Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dotnet-windows-build
|
||||
path: |
|
||||
scanner/
|
||||
main/
|
||||
retention-days: 1
|
||||
|
||||
build_rust_windows:
|
||||
name: Build rust for Windows
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Windows Launcher
|
||||
shell: bash
|
||||
run: cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
|
||||
|
||||
- name: Upload Rust Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rust-windows-build
|
||||
path: ErsatzTV-Windows/target/release/ersatztv_windows.exe
|
||||
retention-days: 1
|
||||
|
||||
package_and_upload_windows:
|
||||
name: Package & Upload Windows
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_dotnet_windows, build_rust_windows]
|
||||
steps:
|
||||
- name: Download dotnet artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dotnet-windows-build
|
||||
path: dotnet-build
|
||||
|
||||
- name: Download rust artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rust-windows-build
|
||||
path: rust-build
|
||||
|
||||
- name: Download ffmpeg
|
||||
uses: suisei-cn/actions-download-file@v1.3.0
|
||||
id: downloadffmpeg
|
||||
with:
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-56-gc2184b65d2-win64-gpl-7.1.zip"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-win-x64"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
mkdir "$release_name"
|
||||
|
||||
mv dotnet-build/scanner/* "$release_name/"
|
||||
mv dotnet-build/main/* "$release_name/"
|
||||
|
||||
# dotnet shouldn't copy the resources here, but it does
|
||||
rm -rf "$release_name/Resources"
|
||||
|
||||
mv rust-build/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: "*win-x64.zip"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: "${{ env.RELEASE_NAME }}.zip"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
|
||||
6
.github/workflows/pr.yml
vendored
6
.github/workflows/pr.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET Core
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET Core
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
209
CHANGELOG.md
209
CHANGELOG.md
@@ -5,6 +5,209 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [25.5.0] - 2025-09-01
|
||||
### Added
|
||||
- Add *experimental* graphics engine
|
||||
- All watermarks will use new graphics engine
|
||||
- Add `Opacity Expression` watermark mode
|
||||
- This allows specifying an expression that returns an opacity between 0.0 and 1.0
|
||||
- The expression can use:
|
||||
- `content_seconds` - the total number of seconds the frame is into the content
|
||||
- `content_total_seconds` - the total number of seconds in the content
|
||||
- `channel_seconds` - the total number of seconds the frame is from when the channel started/activated
|
||||
- `time_of_day_seconds` - the total number of seconds the frame is since midnight
|
||||
- The expression can also use functions:
|
||||
- `LinearFadeDuration(time, start, fadeSeconds, peakSeconds)`
|
||||
- `LinearFadePoints(time, start, peakStart, peakEnd, end)`
|
||||
- Add `Z-Index` to watermark editor
|
||||
- The graphics engine will order by z-index when overlaying watermarks
|
||||
- Add *experimental* `Graphics Element` template system
|
||||
- Graphics elements are defined in YAML files inside ETV config folder / templates / graphics-elements subfolder
|
||||
- Add `text` graphics element type
|
||||
- Supported in playback troubleshooting and YAML playouts
|
||||
- Displays multi-line text in a specified font, color, location, z-index
|
||||
- Supports constant opacity and opacity expression
|
||||
- Supports EPG and Media Item variable replacement
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- Add `image` graphics element type
|
||||
- Supported in playback troubleshooting and YAML playouts
|
||||
- Displays an image, similar to a watermark
|
||||
- Supports constant opacity and opacity expression
|
||||
- Add `subtitle` graphics element type
|
||||
- Supported in playback troubleshooting and YAML playouts
|
||||
- Supports SRT and SSA/ASS subtitle formats
|
||||
- Supports EPG and Media Item variable replacement
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- YAML playout: add `graphics_on` and `graphics_off` instructions to control graphics elements
|
||||
- `graphics_on` requires the name of a graphics element template, e.g. `text/cool_element.yml`
|
||||
- The `variables` property can be used to dynamically replace text from the template
|
||||
- `graphics_off` will turn off a specific element, or all elements if none are specified
|
||||
- Add `Seek Seconds` to playback troubleshooting to support capturing timing-related issues
|
||||
- Custom stream selector: add `content_condition` to allow channel and time-of-day based decisions
|
||||
- `content_condition` expression can use
|
||||
- `channel_number`
|
||||
- `channel_name`
|
||||
- `time_of_day_seconds` - the start time for the current item, represented in seconds since midnight
|
||||
- Add support for external chapter files next to video files
|
||||
- Currently supports Matroska Chapter XML format
|
||||
- Chapter files have .xml or .chapters extension
|
||||
- Add targeted (single-show) library scanning
|
||||
- Supports quick and deep scans
|
||||
- Can be triggered from the `Scan` button on show pages
|
||||
- Can be triggered by API call to `/api/libraries/{library-id}/scan-show`
|
||||
- Add XMLTV setting `XMLTV Block Behavior` to control how block schedules appear in the EPG
|
||||
- `Split Time Evenly` - default (existing) behavior; block time is split among all items that are visible in the EPG
|
||||
- `Use Actual Times` - actual times are used for all items that are visible in the EPG
|
||||
- This will introduce EPG gaps when filler is used, or when items are hidden from the EPG
|
||||
- Add *experimental* `Scripted Schedule` playout system
|
||||
- This system uses python scripts to support the highest degree of customization
|
||||
- The goal is to expose methods equivalent to all sequential schedule (YAML) instructions
|
||||
- YAML and Scripted schedules: add `offline_tail` and `stop_before_end` to `pad_to_next` instruction
|
||||
- Both parameters default to `true`
|
||||
|
||||
### Fix
|
||||
- Fix database operations that were slowing down playout builds
|
||||
- YAML playouts in particular should build significantly faster
|
||||
- Fix channel playout mode `On Demand` for Block and YAML schedules
|
||||
- Fix QSV transitions when remote streaming from a media server
|
||||
- Fix green output when padding with VAAPI accel and i965 driver
|
||||
- Fix watermark custom image validation
|
||||
- Fix playback when using any watermarks that were saved with invalid state (no image)
|
||||
- Fix overlapping block playout items caused by `Stop scheduling block items` value `After Duration End`
|
||||
- Existing overlapping items will not be removed, but no new overlapping items will be created
|
||||
- Until these existing items age out, there will be warnings logged after each playout build/extension
|
||||
- Fix playback of anamorphic content from Jellyfin
|
||||
- This fix requires a manual deep scan of any affected Jellyfin library
|
||||
- Fix bug where multiple Plex servers would mix their episodes
|
||||
- Fix incorrect media item counts after removing paths from local libraries
|
||||
- Fix song playback in playback troubleshooting
|
||||
- Fix seeking into extracted text subtitles
|
||||
- Fix error when changing default (lowest priority) alternate schedule
|
||||
- Fix remote library editing, tv shows, artists with MySql/MariaDB
|
||||
- Classic schedules: fix alternate schedule transitions (some edge cases would cause days to be skipped completely)
|
||||
- Classic schedules: always start new alternate schedules with the first schedule item
|
||||
- Classic Schedules: log offline gaps longer than 1 hour due to strict fixed start times
|
||||
- Fix `HLS Segmenter V2` streaming mode with AMF acceleration
|
||||
- Fix `HLS Segmenter V2` streaming mode with VideoToolbox acceleration
|
||||
- Fix startup process for database and search index initialization
|
||||
- Redirect all pages to home page when initializing to prevent errors
|
||||
- Clear stale sqlite migration lock on startup to prevent getting stuck on database initialization
|
||||
- Fix display of long season placeholder text (when season posters are unavailable)
|
||||
|
||||
### Changed
|
||||
- Rename some schedule and playout terms for clarity
|
||||
- Schedules are used to build playouts and are what actually differs
|
||||
- The playout is the end result, and is the same no matter what schedule kind is used
|
||||
- Supported schedule kinds:
|
||||
- `Classic Schedules`
|
||||
- `Block Schedules`
|
||||
- `Sequential Schedules` (formerly `YAML Schedules` or `YAML Playouts`)
|
||||
- `Scripted Schedules`
|
||||
- `JSON (dizqueTV) Schedules` (formerly `External JSON Playouts`)
|
||||
- Allow multiple watermarks in playback troubleshooting
|
||||
- Classic schedules: allow selecting multiple watermarks on schedule items
|
||||
- Block schedules: allow selecting multiple watermarks on decos
|
||||
- Block schedules: change available watermark modes on decos. For reference, the levels from highest to lowest with block schedules are `Global` > `Channel` > `Playout Default Deco` > `Template Deco`.
|
||||
- `Inherit` - Use watermarks configured at a higher level
|
||||
- `Disable` - Disable watermarks at this level and above
|
||||
- `Replace` - Replace all watermarks configured at a higher level with those on this deco
|
||||
- This was renamed from `Override`
|
||||
- `Merge` - Merge all watermarks configured at a higher level with those on this deco
|
||||
- YAML playout: `watermark` instruction changes:
|
||||
- When value is `true`, will add named watermark to list of active watermarks
|
||||
- When value is `false` and `name` is specified, will remove named watermark from list of active watermarks
|
||||
- When value is `false` and `name` is not specified, will clear all active watermarks
|
||||
- Use consistent UI sorting and validation, and fix renaming errors for
|
||||
- Block groups, blocks
|
||||
- Template groups, templates
|
||||
- Deco groups, decos
|
||||
- Deco template groups, deco templates
|
||||
|
||||
## [25.4.0] - 2025-08-05
|
||||
### Added
|
||||
- Add `Troubleshoot Playback` to overflow menu on all media cards
|
||||
- This should eliminate the need to lookup media ids for content
|
||||
- Add subtitle selection to playback troubleshooting. This is limited to:
|
||||
- Sidecar text subtitles (e.g. `srt` files)
|
||||
- Embedded image subtitles
|
||||
- Embedded text subtitles that have already been extracted by ETV
|
||||
- Add light mode and light/dark mode toggle to app bar
|
||||
- YAML playout: add `pre_roll` instruction to enable and disable a pre-roll sequence
|
||||
- With value of `true` and `sequence` property, will enable automatic pre-roll for all content in the playout to the sequence with the provided key
|
||||
- With value of `false`, will disable automatic pre-roll in the playout
|
||||
- YAML playout: add `post_roll` instruction to enable and disable a post-roll sequence
|
||||
- With value of `true` and `sequence` property, will enable automatic post-roll for all content in the playout to the sequence with the provided key
|
||||
- With value of `false`, will disable automatic post-roll in the playout
|
||||
- YAML playout: add `mid_roll` instruction to enable and disable a mid-roll sequence
|
||||
- With value of `true` and `sequence` property, will enable automatic mid-roll for (`count` and `all`) content in the playout to the sequence with the provided key
|
||||
- With value of `false`, will disable automatic post-roll in the playout
|
||||
- `expression` can be used to influence which chapters are selected for mid roll (same as in filler preset)
|
||||
- YAML playout: add `rewind` instruction to set start of playout relative to the current time
|
||||
- Value should be formatted as `HH:MM:SS` e.g. `00:05:30` for 5 minutes 30 seconds (before now)
|
||||
- This is instruction is mostly useful for debugging transitions, and can only be used as a reset instruction
|
||||
- YAML playout: add `import` section to allow importing partial YAML definitions that include `content` and `sequence` entries
|
||||
- Add YAML playout validation (using JSON Schema)
|
||||
- Invalid YAML playout definitions will fail to build and will log validation failures as warnings
|
||||
- `content` is fully validated
|
||||
- `sequence` is fully validated
|
||||
- `reset` is fully validated
|
||||
- `playout` is fully validated
|
||||
- Add `Playlist` collection type to filler presets
|
||||
- This will force filler mode `Count`
|
||||
- Whenever the filler is used, it will schedule `Count` times full time through the playlist
|
||||
- If the playlist has 3 items and none set to play all, it will schedule 3 items when `Count = 1`
|
||||
- If the playlist has 3 items and none set to play all, it will schedule 6 items when `Count = 2`
|
||||
- Using the same playlist in the same schedule for anything other than filler may cause undesired behavior
|
||||
- Detect supported VideoToolbox hardware decoders and encoders
|
||||
- Software decoders/encoders will automatically be used when hardware versions are unavailable
|
||||
- Add VideoToolbox Capabilities to Troubleshooting page
|
||||
- Add `Use Chapters As Media Items` option to filler preset
|
||||
- This option allows scheduling individual chapters as filler
|
||||
- The chapters are shuffled or otherwise sorted together just like normal filler would be
|
||||
- Add smart collection edit page to allow renaming smart collections
|
||||
- Previous edit link behavior (performing search using smart collection query) now uses magnifying glass icon
|
||||
- Add channel `Transcode Mode` setting
|
||||
- This setting is currently disabled and only has the value `On Demand`
|
||||
- Add channel `Idle Behavior` setting to control the transcoding behavior after all clients have disconnected
|
||||
- `Stop On Disconnect` - stops the transcoder after all clients have disconnected + the global idle timeout
|
||||
- `Keep Running` - transcoder will run until manually stopped
|
||||
- Add support for music video thumbnails that end in `-thumb`
|
||||
- For example `Music Video.mkv` could have a corresponding thumbnail `Music Video-thumb.jpg`
|
||||
- Reorganize troubleshooting page
|
||||
- Add `YAML Validation` tool in `Troubleshooting` > `Tools`
|
||||
|
||||
### Fixed
|
||||
- Fix app startup with MySql/MariaDB
|
||||
- YAML playout: fix `pad_to_next` always running over time
|
||||
- Fix playback with text subtitles when seeking into content, i.e. when first joining a channel
|
||||
- Fix playback with `.ass` and `.ssa` text subtitles
|
||||
- Fix green padding with 10-bit source content and i965 VAAPI driver
|
||||
- Fix building playouts with empty schedules
|
||||
- Fix schedule start time calculation when daily playout build goes beyond midnight and into a different alternate schedule
|
||||
- Fix compatibility with older NVIDIA devices (compute capability 3.0+) in unified docker image
|
||||
- Fix transitions when using NVIDIA, QSV and VAAPI acceleration
|
||||
- Fix playback of remote streams on channels where framerate normalization is enabled
|
||||
|
||||
### Changed
|
||||
- Always tell ffmpeg to stop encoding with a specific duration
|
||||
- This was removed to try to improve transitions with ffmpeg 7.x, but has been causing issues with other content
|
||||
- Move search debug logging to its own log category; add `Searching Minimum Log Level` to `Settings` > `Logging`
|
||||
- Classic schedules: always schedule the full `Duration` amount instead of stopping mid-duration
|
||||
- This allows duration items to be scheduled beyond midnight
|
||||
- e.g. fixed start time 22:00 with 4 hour duration will schedule until 02:00 instead of stopping at midnight
|
||||
- Rename channel setting `Progress Mode` to `Playout Mode`
|
||||
- This controls the progression of the channel's playout, and has nothing to do with transcoding
|
||||
- `Always` is now called `Continuous` (playout progresses with wall clock)
|
||||
- `On Demand` is unchanged (playout only progresses while a client is watching the channel)
|
||||
- Replace channel `Active Mode` setting with new `Is Enabled` and `Show In EPG` settings
|
||||
- `Active` channels will be converted to `Is Enabled` = true and `Show In EPG` = true
|
||||
- `Hidden` channels will be converted to `Is Enabled` = true and `Show In EPG` = false
|
||||
- `Inactive` channels will be converted to `Is Enabled` = false and `Show In EPG` = false
|
||||
|
||||
## [25.3.1] - 2025-07-24
|
||||
### Fixed
|
||||
- Fix fallback filler playback
|
||||
@@ -2424,7 +2627,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.3.1...HEAD
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.5.0...HEAD
|
||||
[25.5.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.4.0...v25.5.0
|
||||
[25.4.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.3.1...v25.4.0
|
||||
[25.3.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.3.0...v25.3.1
|
||||
[25.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.2.0...v25.3.0
|
||||
[25.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.1.0...v25.2.0
|
||||
@@ -2553,4 +2758,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
[0.0.5-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.4-prealpha...v0.0.5-prealpha
|
||||
[0.0.4-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.3-prealpha...v0.0.4-prealpha
|
||||
[0.0.3-prealpha]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.0.1-prealpha...v0.0.3-prealpha
|
||||
[0.0.1-prealpha]: https://github.com/ErsatzTV/ErsatzTV/releases/tag/v0.0.1-prealpha
|
||||
[0.0.1-prealpha]: https://github.com/ErsatzTV/ErsatzTV/releases/tag/v0.0.1-prealpha
|
||||
|
||||
@@ -21,7 +21,7 @@ public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
|
||||
Option<Artwork> artwork = await dbContext.Artwork
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.Id)
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.Id, cancellationToken)
|
||||
.MapT(Project);
|
||||
|
||||
return artwork.ToEither(BaseError.New("Artwork not found"));
|
||||
|
||||
@@ -16,7 +16,7 @@ public record ChannelViewModel(
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -26,7 +26,10 @@ public record ChannelViewModel(
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelActiveMode ActiveMode)
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg)
|
||||
{
|
||||
public string WebEncodedName => WebUtility.UrlEncode(Name);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public record CreateChannel(
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -24,4 +24,7 @@ public record CreateChannel(
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
|
||||
@@ -22,7 +22,7 @@ public class CreateChannelHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||
}
|
||||
|
||||
@@ -35,11 +35,11 @@ public class CreateChannelHandler(
|
||||
return new CreateChannelResult(channel.Id);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
|
||||
(ValidateName(request), await ValidateNumber(dbContext, request),
|
||||
await FFmpegProfileMustExist(dbContext, request),
|
||||
await WatermarkMustExist(dbContext, request),
|
||||
await FillerPresetMustExist(dbContext, request))
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) =>
|
||||
(ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await WatermarkMustExist(dbContext, request, cancellationToken),
|
||||
await FillerPresetMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((
|
||||
name,
|
||||
number,
|
||||
@@ -76,7 +76,7 @@ public class CreateChannelHandler(
|
||||
Group = request.Group,
|
||||
Categories = request.Categories,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
ProgressMode = request.ProgressMode,
|
||||
PlayoutMode = request.PlayoutMode,
|
||||
StreamingMode = request.StreamingMode,
|
||||
Artwork = artwork,
|
||||
StreamSelectorMode = request.StreamSelectorMode,
|
||||
@@ -88,7 +88,10 @@ public class CreateChannelHandler(
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
|
||||
SongVideoMode = request.SongVideoMode,
|
||||
ActiveMode = request.ActiveMode
|
||||
TranscodeMode = request.TranscodeMode,
|
||||
IdleBehavior = request.IdleBehavior,
|
||||
IsEnabled = request.IsEnabled,
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg
|
||||
};
|
||||
|
||||
foreach (int id in watermarkId)
|
||||
@@ -110,10 +113,11 @@ public class CreateChannelHandler(
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Channel> maybeExistingChannel = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number);
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number, cancellationToken);
|
||||
return maybeExistingChannel.Match<Validation<BaseError, string>>(
|
||||
_ => BaseError.New("Channel number must be unique"),
|
||||
() =>
|
||||
@@ -129,9 +133,10 @@ public class CreateChannelHandler(
|
||||
|
||||
private static Task<Validation<BaseError, int>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel) =>
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.CountAsync(p => p.Id == createChannel.FFmpegProfileId)
|
||||
.CountAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken)
|
||||
.Map(Optional)
|
||||
.Filter(c => c > 0)
|
||||
.MapT(_ => createChannel.FFmpegProfileId)
|
||||
@@ -139,7 +144,8 @@ public class CreateChannelHandler(
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (createChannel.WatermarkId is null)
|
||||
{
|
||||
@@ -147,7 +153,7 @@ public class CreateChannelHandler(
|
||||
}
|
||||
|
||||
return await dbContext.ChannelWatermarks
|
||||
.CountAsync(w => w.Id == createChannel.WatermarkId)
|
||||
.CountAsync(w => w.Id == createChannel.WatermarkId, cancellationToken)
|
||||
.Map(Optional)
|
||||
.Filter(c => c > 0)
|
||||
.MapT(_ => Optional(createChannel.WatermarkId))
|
||||
@@ -156,7 +162,8 @@ public class CreateChannelHandler(
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (createChannel.FallbackFillerId is null)
|
||||
{
|
||||
@@ -165,7 +172,7 @@ public class CreateChannelHandler(
|
||||
|
||||
return await dbContext.FillerPresets
|
||||
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
|
||||
.CountAsync(w => w.Id == createChannel.FallbackFillerId)
|
||||
.CountAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken)
|
||||
.Map(Optional)
|
||||
.Filter(c => c > 0)
|
||||
.MapT(_ => Optional(createChannel.FallbackFillerId))
|
||||
|
||||
@@ -31,7 +31,7 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
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);
|
||||
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||
}
|
||||
|
||||
@@ -57,10 +57,11 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteChannel deleteChannel)
|
||||
DeleteChannel deleteChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId);
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId, cancellationToken);
|
||||
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +52,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
int inactiveCount = await dbContext.Channels
|
||||
.Where(c => c.Number == request.ChannelNumber && c.ActiveMode != ChannelActiveMode.Active)
|
||||
int hiddenCount = await dbContext.Channels
|
||||
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
|
||||
.CountAsync(cancellationToken);
|
||||
if (inactiveCount > 0)
|
||||
if (hiddenCount > 0)
|
||||
{
|
||||
File.Delete(targetFile);
|
||||
return;
|
||||
@@ -183,17 +183,18 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
int daysToBuild = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild)
|
||||
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
|
||||
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
switch (playout.ProgramSchedulePlayoutType)
|
||||
switch (playout.ScheduleKind)
|
||||
{
|
||||
case ProgramSchedulePlayoutType.Flood:
|
||||
case ProgramSchedulePlayoutType.Yaml:
|
||||
case PlayoutScheduleKind.Classic:
|
||||
case PlayoutScheduleKind.Sequential:
|
||||
case PlayoutScheduleKind.Scripted:
|
||||
var floodSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
@@ -209,9 +210,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.Block:
|
||||
case PlayoutScheduleKind.Block:
|
||||
var blockSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
@@ -227,10 +229,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.ExternalJson:
|
||||
var externalJsonSorted = (await CollectExternalJsonItems(playout.ExternalJsonFile))
|
||||
case PlayoutScheduleKind.ExternalJson:
|
||||
var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile))
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
|
||||
@@ -244,7 +247,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -267,10 +271,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml)
|
||||
XmlWriter xml,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
XmltvTimeZone xmltvTimeZone = await _configElementRepository
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone)
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
|
||||
.IfNoneAsync(XmltvTimeZone.Local);
|
||||
|
||||
// skip all filler that isn't pre-roll
|
||||
@@ -356,59 +361,106 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml)
|
||||
XmlWriter xml,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
XmltvTimeZone xmltvTimeZone = await _configElementRepository
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone)
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
|
||||
.IfNoneAsync(XmltvTimeZone.Local);
|
||||
|
||||
XmltvBlockBehavior xmltvBlockBehavior = await _configElementRepository
|
||||
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
|
||||
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
|
||||
|
||||
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
|
||||
foreach (var group in groups)
|
||||
{
|
||||
DateTime groupStart = group.Key.GuideStart!.Value;
|
||||
DateTime groupFinish = group.Key.GuideFinish!.Value;
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
|
||||
if (itemsToInclude.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = xmltvTimeZone switch
|
||||
switch (xmltvBlockBehavior)
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
case XmltvBlockBehavior.UseActualTimes:
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
DateTimeOffset actualStart = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
DateTimeOffset actualFinish = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
item,
|
||||
start,
|
||||
stop,
|
||||
false,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
item,
|
||||
start,
|
||||
stop,
|
||||
false,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
}
|
||||
break;
|
||||
case XmltvBlockBehavior.SplitTimeEvenly:
|
||||
default:
|
||||
DateTime groupStart = group.Key.GuideStart!.Value;
|
||||
DateTime groupFinish = group.Key.GuideFinish!.Value;
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
item,
|
||||
start,
|
||||
stop,
|
||||
false,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
|
||||
from Channel C
|
||||
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
|
||||
where C.Id in (select ChannelId from Playout) and C.ActiveMode = 0
|
||||
where C.Id in (select ChannelId from Playout) and C.IsEnabled = 1 and C.ShowInEPG = 1
|
||||
order by CAST(C.Number as double)";
|
||||
// TODO: this needs to be fixed for sqlite/mariadb
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ public record UpdateChannel(
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -25,4 +25,7 @@ public record UpdateChannel(
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -23,11 +23,15 @@ public class UpdateChannelHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
Channel c,
|
||||
UpdateChannel update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
c.Name = update.Name;
|
||||
c.Number = update.Number;
|
||||
@@ -43,7 +47,10 @@ public class UpdateChannelHandler(
|
||||
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
|
||||
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
|
||||
c.SongVideoMode = update.SongVideoMode;
|
||||
c.ActiveMode = update.ActiveMode;
|
||||
c.TranscodeMode = update.TranscodeMode;
|
||||
c.IdleBehavior = update.IdleBehavior;
|
||||
c.IsEnabled = update.IsEnabled;
|
||||
c.ShowInEpg = update.IsEnabled && update.ShowInEpg;
|
||||
c.Artwork ??= [];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
|
||||
@@ -83,7 +90,7 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
await dbContext.Entry(c)
|
||||
.Collection(channel => channel.Artwork)
|
||||
.LoadAsync();
|
||||
.LoadAsync(cancellationToken);
|
||||
|
||||
foreach (Artwork artwork in c.Artwork.Where(x => x.ArtworkKind is ArtworkKind.Logo).ToList())
|
||||
{
|
||||
@@ -92,42 +99,46 @@ public class UpdateChannelHandler(
|
||||
}
|
||||
}
|
||||
|
||||
c.ProgressMode = update.ProgressMode;
|
||||
c.PlayoutMode = update.PlayoutMode;
|
||||
c.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
if (c.SubtitleMode != ChannelSubtitleMode.None)
|
||||
{
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id);
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, cancellationToken);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList());
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return ProjectToViewModel(c);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
|
||||
(await ChannelMustExist(dbContext, request), ValidateName(request),
|
||||
await ValidateNumber(dbContext, request))
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ChannelMustExist(dbContext, request, cancellationToken), ValidateName(request),
|
||||
await ValidateNumber(dbContext, request, cancellationToken))
|
||||
.Apply((channelToUpdate, _, _) => channelToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateChannel updateChannel) =>
|
||||
UpdateChannel updateChannel,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Channels
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Channel does not exist."));
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(UpdateChannel updateChannel) =>
|
||||
@@ -136,10 +147,11 @@ public class UpdateChannelHandler(
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||
TvContext dbContext,
|
||||
UpdateChannel updateChannel)
|
||||
UpdateChannel updateChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int matchId = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number)
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number, cancellationToken)
|
||||
.Match(c => c.Id, () => updateChannel.ChannelId);
|
||||
|
||||
if (matchId == updateChannel.ChannelId)
|
||||
|
||||
@@ -19,7 +19,7 @@ internal static class Mapper
|
||||
channel.StreamSelector,
|
||||
channel.PreferredAudioLanguageCode,
|
||||
channel.PreferredAudioTitle,
|
||||
channel.ProgressMode,
|
||||
channel.PlayoutMode,
|
||||
channel.StreamingMode,
|
||||
channel.WatermarkId,
|
||||
channel.FallbackFillerId,
|
||||
@@ -29,7 +29,10 @@ internal static class Mapper
|
||||
channel.MusicVideoCreditsMode,
|
||||
channel.MusicVideoCreditsTemplate,
|
||||
channel.SongVideoMode,
|
||||
channel.ActiveMode);
|
||||
channel.TranscodeMode,
|
||||
channel.IdleBehavior,
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi,
|
||||
GetAllChannelsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll()).Flatten();
|
||||
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
return channels.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@ using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetAllChannelsHandler : IRequestHandler<GetAllChannels, List<ChannelViewModel>>
|
||||
public class GetAllChannelsHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetAllChannels, List<ChannelViewModel>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
|
||||
Optional(await _channelRepository.GetAll()).Flatten().Map(ProjectToViewModel).ToList();
|
||||
await channelRepository.GetAll(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
@@ -21,82 +21,100 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
|
||||
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
FFmpegProfile ffmpegProfile = await dbContext.Channels
|
||||
.Filter(c => c.Number == request.ChannelNumber)
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Map(c => c.FFmpegProfile)
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
if (!ffmpegProfile.NormalizeFramerate)
|
||||
try
|
||||
{
|
||||
return Option<int>.None;
|
||||
}
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// TODO: expand to check everything in collection rather than what's scheduled?
|
||||
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
FFmpegProfile ffmpegProfile = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Filter(c => c.Number == request.ChannelNumber)
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Map(c => c.FFmpegProfile)
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.Filter(p => p.Channel.Number == request.ChannelNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
|
||||
.Flatten()
|
||||
.Map(mv => mv.RFrameRate)
|
||||
.ToList();
|
||||
|
||||
var distinct = frameRates.Distinct().ToList();
|
||||
if (distinct.Count > 1)
|
||||
{
|
||||
// TODO: something more intelligent than minimum framerate?
|
||||
int result = frameRates.Map(ParseFrameRate).Min();
|
||||
if (result < 24)
|
||||
if (!ffmpegProfile.NormalizeFramerate)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
24,
|
||||
result);
|
||||
|
||||
return 24;
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
result);
|
||||
return result;
|
||||
}
|
||||
// TODO: expand to check everything in collection rather than what's scheduled?
|
||||
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
|
||||
if (distinct.Count != 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Image).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as RemoteStream).MediaVersions)
|
||||
.Filter(p => p.Channel.Number == request.ChannelNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
|
||||
.Flatten()
|
||||
.Map(mv => mv.RFrameRate)
|
||||
.ToList();
|
||||
|
||||
var distinct = frameRates.Distinct().ToList();
|
||||
if (distinct.Count > 1)
|
||||
{
|
||||
// TODO: something more intelligent than minimum framerate?
|
||||
int result = frameRates.Map(ParseFrameRate).Min();
|
||||
if (result < 24)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
24,
|
||||
result);
|
||||
|
||||
return 24;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (distinct.Count != 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Unexpected error checking frame rates on channel {ChannelNumber}",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -31,8 +30,8 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var inactiveChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ActiveMode != ChannelActiveMode.Active)
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
.AsEnumerable()
|
||||
.Select(n => $"{n}.xml")
|
||||
@@ -68,7 +67,7 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inactiveChannelNumbers.Contains(Path.GetFileName(fileName)))
|
||||
if (hiddenChannelNumbers.Contains(Path.GetFileName(fileName)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Hdhr;
|
||||
using ErsatzTV.Core.Hdhr;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
@@ -11,7 +10,7 @@ public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<Li
|
||||
public GetChannelLineupHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
.Map(channels => channels.Where(c => c.ActiveMode is ChannelActiveMode.Active)
|
||||
_channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => channels.Where(c => c.IsEnabled)
|
||||
.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameBy
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
|
||||
.MapT(p => p.Channel.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
_channelRepository = channelRepository;
|
||||
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
_channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(
|
||||
request.Scheme,
|
||||
@@ -27,7 +27,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
var result = new List<Channel>();
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
if (channel.ActiveMode is not ChannelActiveMode.Active)
|
||||
if (!channel.IsEnabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class GetChannelResolutionAndBitrateHandler(IDbContextFactory<TvContext>
|
||||
.AsNoTracking()
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.ThenInclude(ff => ff.Resolution)
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
|
||||
|
||||
return maybeChannel.Map(c => Mapper.ProjectToViewModel(
|
||||
c.FFmpegProfile.Resolution,
|
||||
|
||||
@@ -10,5 +10,5 @@ public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementBy
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken) =>
|
||||
await _configElementRepository.Upsert(request.Key, request.Value);
|
||||
await _configElementRepository.Upsert(request.Key, request.Value, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ public class UpdateLibraryRefreshIntervalHandler :
|
||||
Validate(request)
|
||||
.MapT(_ => _configElementRepository.Upsert(
|
||||
ConfigElementKey.LibraryRefreshInterval,
|
||||
request.LibraryRefreshInterval))
|
||||
request.LibraryRefreshInterval,
|
||||
cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
|
||||
|
||||
@@ -19,31 +19,36 @@ public class UpdateLoggingSettingsHandler : IRequestHandler<UpdateLoggingSetting
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateLoggingSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings);
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings, cancellationToken);
|
||||
|
||||
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings)
|
||||
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = loggingSettings.DefaultMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelScanning,
|
||||
loggingSettings.ScanningMinimumLogLevel);
|
||||
loggingSettings.ScanningMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = loggingSettings.ScanningMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelScheduling,
|
||||
loggingSettings.SchedulingMinimumLogLevel);
|
||||
loggingSettings.SchedulingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = loggingSettings.SchedulingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelSearching,
|
||||
loggingSettings.SearchingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.SearchingLevelSwitch.MinimumLevel = loggingSettings.SearchingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelStreaming,
|
||||
loggingSettings.StreamingMinimumLogLevel);
|
||||
loggingSettings.StreamingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = loggingSettings.StreamingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelHttp,
|
||||
loggingSettings.HttpMinimumLogLevel);
|
||||
loggingSettings.HttpMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = loggingSettings.HttpMinimumLogLevel;
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -32,24 +32,24 @@ public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSetting
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Unit> validation = await Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.PlayoutSettings));
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.PlayoutSettings, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(TvContext dbContext, PlayoutSettingsViewModel playoutSettings)
|
||||
private async Task<Unit> ApplyUpdate(TvContext dbContext, PlayoutSettingsViewModel playoutSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, playoutSettings.DaysToBuild);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, playoutSettings.DaysToBuild, cancellationToken);
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.PlayoutSkipMissingItems,
|
||||
playoutSettings.SkipMissingItems);
|
||||
playoutSettings.SkipMissingItems, cancellationToken);
|
||||
|
||||
// continue all playouts to proper number of days
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number, CultureInfo.InvariantCulture))
|
||||
.Map(p => p.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue));
|
||||
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue), cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -20,7 +20,7 @@ public class UpdateXmltvSettingsHandler(
|
||||
{
|
||||
int playoutDaysToBuild =
|
||||
await configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
|
||||
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild, cancellationToken)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
if (playoutDaysToBuild < request.XmltvSettings.DaysToBuild)
|
||||
@@ -29,19 +29,20 @@ public class UpdateXmltvSettingsHandler(
|
||||
$"XMLTV days to build ({request.XmltvSettings.DaysToBuild}) cannot be greater than Playout days to build ({playoutDaysToBuild})");
|
||||
}
|
||||
|
||||
return await ApplyUpdate(request.XmltvSettings);
|
||||
return await ApplyUpdate(request.XmltvSettings, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings)
|
||||
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone, cancellationToken);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild, cancellationToken);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvBlockBehavior, xmltvSettings.BlockBehavior, cancellationToken);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync())
|
||||
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync(cancellationToken))
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber));
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -7,6 +7,7 @@ public class LoggingSettingsViewModel
|
||||
public LogEventLevel DefaultMinimumLogLevel { get; set; }
|
||||
public LogEventLevel ScanningMinimumLogLevel { get; set; }
|
||||
public LogEventLevel SchedulingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel SearchingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel StreamingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel HttpMinimumLogLevel { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@ public class GetConfigElementByKeyHandler : IRequestHandler<GetConfigElementByKe
|
||||
public Task<Option<ConfigElementViewModel>> Handle(
|
||||
GetConfigElementByKey request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetConfigElement(request.Key).MapT(ProjectToViewModel);
|
||||
_configElementRepository.GetConfigElement(request.Key, cancellationToken).MapT(ProjectToViewModel);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,6 @@ public class GetLibraryRefreshIntervalHandler : IRequestHandler<GetLibraryRefres
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<int> Handle(GetLibraryRefreshInterval request, CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
|
||||
.Map(result => result.IfNone(6));
|
||||
}
|
||||
|
||||
@@ -14,25 +14,39 @@ public class GetLoggingSettingsHandler : IRequestHandler<GetLoggingSettings, Log
|
||||
public async Task<LoggingSettingsViewModel> Handle(GetLoggingSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LogEventLevel> maybeDefaultLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel, cancellationToken);
|
||||
|
||||
Option<LogEventLevel> maybeScanningLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScanning);
|
||||
await _configElementRepository.GetValue<LogEventLevel>(
|
||||
ConfigElementKey.MinimumLogLevelScanning,
|
||||
cancellationToken);
|
||||
|
||||
Option<LogEventLevel> maybeSchedulingLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScheduling);
|
||||
await _configElementRepository.GetValue<LogEventLevel>(
|
||||
ConfigElementKey.MinimumLogLevelScheduling,
|
||||
cancellationToken);
|
||||
|
||||
Option<LogEventLevel> maybeSearchingLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(
|
||||
ConfigElementKey.MinimumLogLevelSearching,
|
||||
cancellationToken);
|
||||
|
||||
Option<LogEventLevel> maybeStreamingLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelStreaming);
|
||||
await _configElementRepository.GetValue<LogEventLevel>(
|
||||
ConfigElementKey.MinimumLogLevelStreaming,
|
||||
cancellationToken);
|
||||
|
||||
Option<LogEventLevel> maybeHttpLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelHttp);
|
||||
await _configElementRepository.GetValue<LogEventLevel>(
|
||||
ConfigElementKey.MinimumLogLevelHttp,
|
||||
cancellationToken);
|
||||
|
||||
return new LoggingSettingsViewModel
|
||||
{
|
||||
DefaultMinimumLogLevel = await maybeDefaultLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
ScanningMinimumLogLevel = await maybeScanningLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
SchedulingMinimumLogLevel = await maybeSchedulingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
SearchingMinimumLogLevel = await maybeSearchingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
StreamingMinimumLogLevel = await maybeStreamingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
HttpMinimumLogLevel = await maybeHttpLevel.IfNoneAsync(LogEventLevel.Information)
|
||||
};
|
||||
|
||||
@@ -12,10 +12,12 @@ public class GetPlayoutSettingsHandler : IRequestHandler<GetPlayoutSettings, Pla
|
||||
|
||||
public async Task<PlayoutSettingsViewModel> Handle(GetPlayoutSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
|
||||
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.PlayoutDaysToBuild,
|
||||
cancellationToken);
|
||||
|
||||
Option<bool> skipMissingItems =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems);
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems, cancellationToken);
|
||||
|
||||
return new PlayoutSettingsViewModel
|
||||
{
|
||||
|
||||
@@ -8,15 +8,23 @@ public class GetXmltvSettingsHandler(IConfigElementRepository configElementRepos
|
||||
{
|
||||
public async Task<XmltvSettingsViewModel> Handle(GetXmltvSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> daysToBuild = await configElementRepository.GetValue<int>(ConfigElementKey.XmltvDaysToBuild);
|
||||
Option<int> daysToBuild = await configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.XmltvDaysToBuild,
|
||||
cancellationToken);
|
||||
|
||||
Option<XmltvTimeZone> maybeTimeZone =
|
||||
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone);
|
||||
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken);
|
||||
|
||||
Option<XmltvBlockBehavior> maybeBlockBehavior =
|
||||
await configElementRepository.GetValue<XmltvBlockBehavior>(
|
||||
ConfigElementKey.XmltvBlockBehavior,
|
||||
cancellationToken);
|
||||
|
||||
return new XmltvSettingsViewModel
|
||||
{
|
||||
DaysToBuild = await daysToBuild.IfNoneAsync(2),
|
||||
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local)
|
||||
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local),
|
||||
BlockBehavior = await maybeBlockBehavior.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
7
ErsatzTV.Application/Configuration/XmltvBlockBehavior.cs
Normal file
7
ErsatzTV.Application/Configuration/XmltvBlockBehavior.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public enum XmltvBlockBehavior
|
||||
{
|
||||
SplitTimeEvenly = 0,
|
||||
UseActualTimes = 1
|
||||
}
|
||||
@@ -4,4 +4,5 @@ public class XmltvSettingsViewModel
|
||||
{
|
||||
public int DaysToBuild { get; set; }
|
||||
public XmltvTimeZone TimeZone { get; set; }
|
||||
public XmltvBlockBehavior BlockBehavior { get; set; }
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
@@ -40,10 +40,13 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeEmbyCollections request)
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyMediaSources
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId)
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId, cancellationToken)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
|
||||
@@ -38,7 +38,7 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
@@ -77,10 +77,11 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeEmbyLibraryById request)
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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 Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyShowById>,
|
||||
IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>
|
||||
{
|
||||
public CallEmbyShowScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle(
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-show",
|
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DateTimeOffset.MinValue);
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
SynchronizeEmbyShowById request) =>
|
||||
true;
|
||||
}
|
||||
@@ -33,18 +33,21 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
SynchronizeEmbyLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(SynchronizeLibraries)
|
||||
Validate(request, cancellationToken)
|
||||
.MapT(p => SynchronizeLibraries(p, cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeEmbyLibraries request) =>
|
||||
MediaSourceMustExist(request)
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(
|
||||
SynchronizeEmbyLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
MediaSourceMustExist(request, cancellationToken)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> MediaSourceMustExist(
|
||||
SynchronizeEmbyLibraries request) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
|
||||
SynchronizeEmbyLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Emby media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
@@ -65,7 +68,9 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
||||
.ToValidation<BaseError>("Emby media source requires an api key");
|
||||
}
|
||||
|
||||
private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters)
|
||||
private async Task<Unit> SynchronizeLibraries(
|
||||
ConnectionParameters connectionParameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, List<EmbyLibrary>> maybeLibraries = await _embyApiClient.GetLibraries(
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
@@ -91,7 +96,8 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
||||
connectionParameters.EmbyMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate);
|
||||
toUpdate,
|
||||
cancellationToken);
|
||||
if (ids.Count != 0)
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
@@ -23,7 +23,7 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
|
||||
SynchronizeEmbyMediaSources request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby();
|
||||
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby(cancellationToken);
|
||||
foreach (EmbyMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyShowById(int EmbyLibraryId, int ShowId, bool DeepScan)
|
||||
: IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest;
|
||||
@@ -23,7 +23,7 @@ public class
|
||||
UpdateEmbyLibraryPreferences request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
var toDisable = request.Preferences.Filter(p => !p.ShouldSyncItems).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisableEmbyLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -15,7 +15,7 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateEmbyPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
Validate(request, cancellationToken)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
@@ -37,12 +37,12 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request) =>
|
||||
EmbyMediaSourceMustExist(request);
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
EmbyMediaSourceMustExist(request, cancellationToken);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
|
||||
UpdateEmbyPathReplacements request) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
|
||||
UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@ public class GetAllEmbyMediaSourcesHandler : IRequestHandler<GetAllEmbyMediaSour
|
||||
public Task<List<EmbyMediaSourceViewModel>> Handle(
|
||||
GetAllEmbyMediaSources request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetAllEmby().Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
_mediaSourceRepository.GetAllEmby(cancellationToken).Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
}
|
||||
|
||||
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
|
||||
await Validate()
|
||||
await Validate(cancellationToken)
|
||||
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address, cp.ApiKey))
|
||||
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
|
||||
|
||||
@@ -47,13 +47,14 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
error => error);
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
||||
EmbyMediaSourceMustExist()
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(CancellationToken cancellationToken) =>
|
||||
EmbyMediaSourceMustExist(cancellationToken)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
|
||||
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
|
||||
private Task<Validation<BaseError, EmbyMediaSource>>
|
||||
EmbyMediaSourceMustExist(CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetAllEmby(cancellationToken).Map(list => list.HeadOrNone())
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
"Emby media source does not exist."));
|
||||
|
||||
|
||||
@@ -14,5 +14,5 @@ public class
|
||||
public Task<Option<EmbyMediaSourceViewModel>> Handle(
|
||||
GetEmbyMediaSourceById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId).MapT(ProjectToViewModel);
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken).MapT(ProjectToViewModel);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="4.0.0" />
|
||||
<PackageReference Include="Bugsnag" Version="4.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.9.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.8" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -30,4 +30,4 @@
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=hdhr_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=hdhr_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=health_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -24,7 +24,7 @@ public class CreateFFmpegProfileHandler :
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||
}
|
||||
|
||||
@@ -40,8 +40,10 @@ public class CreateFFmpegProfileHandler :
|
||||
|
||||
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateFFmpegProfile request) =>
|
||||
(ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(dbContext, request))
|
||||
CreateFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(ValidateName(request), ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
|
||||
{
|
||||
Name = name,
|
||||
@@ -56,7 +58,12 @@ public class CreateFFmpegProfileHandler :
|
||||
VideoProfile = request.VideoProfile,
|
||||
VideoPreset = request.VideoPreset,
|
||||
AllowBFrames = request.AllowBFrames,
|
||||
BitDepth = request.BitDepth,
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: request.BitDepth,
|
||||
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
TonemapAlgorithm = request.TonemapAlgorithm,
|
||||
@@ -79,9 +86,10 @@ public class CreateFFmpegProfileHandler :
|
||||
|
||||
private static Task<Validation<BaseError, int>> ResolutionMustExist(
|
||||
TvContext dbContext,
|
||||
CreateFFmpegProfile createFFmpegProfile) =>
|
||||
CreateFFmpegProfile createFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Resolutions
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId)
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId, cancellationToken)
|
||||
.MapT(r => r.Id)
|
||||
.Map(o => o.ToValidation<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist"));
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
|
||||
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
}
|
||||
|
||||
@@ -37,8 +37,9 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
|
||||
|
||||
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request) =>
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class NewFFmpegProfileHandler : IRequestHandler<NewFFmpegProfile, FFmpegP
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
int defaultResolutionId = await dbContext.ConfigElements
|
||||
.GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId)
|
||||
.GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId, cancellationToken)
|
||||
.IfNoneAsync(0);
|
||||
|
||||
List<Resolution> allResolutions = await dbContext.Resolutions
|
||||
|
||||
@@ -24,14 +24,15 @@ public class
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
FFmpegProfile p,
|
||||
UpdateFFmpegProfile update)
|
||||
UpdateFFmpegProfile update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
p.Name = update.Name;
|
||||
p.ThreadCount = update.ThreadCount;
|
||||
@@ -48,7 +49,7 @@ public class
|
||||
p.AllowBFrames = update.AllowBFrames;
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
p.BitDepth = update.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video
|
||||
p.BitDepth = update.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: update.BitDepth;
|
||||
|
||||
@@ -63,7 +64,7 @@ public class
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeFramerate = update.NormalizeFramerate;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
|
||||
@@ -72,16 +73,19 @@ public class
|
||||
|
||||
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile request) =>
|
||||
(await FFmpegProfileMustExist(dbContext, request), ValidateName(request), ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request))
|
||||
UpdateFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustExist(dbContext, request, cancellationToken), ValidateName(request),
|
||||
ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
UpdateFFmpegProfile updateFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
@@ -93,9 +97,10 @@ public class
|
||||
|
||||
private static Task<Validation<BaseError, int>> ResolutionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
UpdateFFmpegProfile updateFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Resolutions
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId)
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken)
|
||||
.MapT(r => r.Id)
|
||||
.Map(o => o.ToValidation<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist"));
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
UpdateFFmpegSettings request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(_ => ApplyUpdate(request))
|
||||
.MapT(_ => ApplyUpdate(request, cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
|
||||
@@ -69,19 +69,22 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
: BaseError.New($"Unable to verify {name} version");
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
|
||||
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath, cancellationToken);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath, cancellationToken);
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegDefaultProfileId,
|
||||
request.Settings.DefaultFFmpegProfileId.ToString(CultureInfo.InvariantCulture));
|
||||
request.Settings.DefaultFFmpegProfileId.ToString(CultureInfo.InvariantCulture),
|
||||
cancellationToken);
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegSaveReports,
|
||||
request.Settings.SaveReports.ToString());
|
||||
request.Settings.SaveReports.ToString(),
|
||||
cancellationToken);
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegHlsDirectOutputFormat,
|
||||
request.Settings.HlsDirectOutputFormat);
|
||||
request.Settings.HlsDirectOutputFormat,
|
||||
cancellationToken);
|
||||
|
||||
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
|
||||
{
|
||||
@@ -90,61 +93,69 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegPreferredLanguageCode,
|
||||
request.Settings.PreferredAudioLanguageCode);
|
||||
request.Settings.PreferredAudioLanguageCode,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
|
||||
request.Settings.UseEmbeddedSubtitles);
|
||||
request.Settings.UseEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
|
||||
// do not extract when subtitles are not used
|
||||
if (request.Settings.UseEmbeddedSubtitles == false)
|
||||
if (!request.Settings.UseEmbeddedSubtitles)
|
||||
{
|
||||
request.Settings.ExtractEmbeddedSubtitles = false;
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
|
||||
request.Settings.ExtractEmbeddedSubtitles);
|
||||
request.Settings.ExtractEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
|
||||
// queue extracting all embedded subtitles
|
||||
if (request.Settings.ExtractEmbeddedSubtitles)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None));
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
|
||||
}
|
||||
|
||||
if (request.Settings.GlobalWatermarkId is not null)
|
||||
{
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegGlobalWatermarkId,
|
||||
request.Settings.GlobalWatermarkId.Value);
|
||||
request.Settings.GlobalWatermarkId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
}
|
||||
|
||||
if (request.Settings.GlobalFallbackFillerId is not null)
|
||||
{
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegGlobalFallbackFillerId,
|
||||
request.Settings.GlobalFallbackFillerId.Value);
|
||||
request.Settings.GlobalFallbackFillerId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId);
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegSegmenterTimeout,
|
||||
request.Settings.HlsSegmenterIdleTimeout);
|
||||
request.Settings.HlsSegmenterIdleTimeout,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegWorkAheadSegmenters,
|
||||
request.Settings.WorkAheadSegmenterLimit);
|
||||
request.Settings.WorkAheadSegmenterLimit,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegInitialSegmentCount,
|
||||
request.Settings.InitialSegmentCount);
|
||||
request.Settings.InitialSegmentCount,
|
||||
cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -4,18 +4,14 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class GetAllFFmpegProfilesHandler : IRequestHandler<GetAllFFmpegProfiles, List<FFmpegProfileViewModel>>
|
||||
public class GetAllFFmpegProfilesHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllFFmpegProfiles, List<FFmpegProfileViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllFFmpegProfilesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<FFmpegProfileViewModel>> Handle(
|
||||
GetAllFFmpegProfiles request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.FFmpegProfiles
|
||||
.Include(p => p.Resolution)
|
||||
.ToListAsync(cancellationToken)
|
||||
|
||||
@@ -22,7 +22,7 @@ public class
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.FFmpegProfiles
|
||||
.Include(p => p.Resolution)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.Id)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken)
|
||||
.MapT(ProjectToFullResponseModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById,
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.FFmpegProfiles
|
||||
.Include(p => p.Resolution)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.Id)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken)
|
||||
.MapT(ProjectToViewModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,30 +15,30 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
|
||||
GetFFmpegSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> ffmpegPath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath);
|
||||
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath);
|
||||
Option<string> ffmpegPath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken);
|
||||
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken);
|
||||
Option<int> defaultFFmpegProfileId =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
|
||||
Option<bool> saveReports =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken);
|
||||
Option<string> preferredAudioLanguageCode =
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode, cancellationToken);
|
||||
Option<bool> useEmbeddedSubtitles =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles);
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken);
|
||||
Option<bool> extractEmbeddedSubtitles =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles);
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken);
|
||||
Option<int> watermark =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
Option<int> fallbackFiller =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId);
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
|
||||
Option<int> hlsSegmenterIdleTimeout =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout);
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken);
|
||||
Option<int> workAheadSegmenterLimit =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken);
|
||||
Option<int> initialSegmentCount =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount);
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken);
|
||||
Option<OutputFormatKind> outputFormatKind =
|
||||
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat);
|
||||
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
|
||||
|
||||
var result = new FFmpegSettingsViewModel
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@ public class
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, string> validation = await Validate(dbContext);
|
||||
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
|
||||
|
||||
return await validation.Match(
|
||||
GetHardwareAccelerationKinds,
|
||||
@@ -69,11 +69,11 @@ public class
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, string>> Validate(TvContext dbContext) =>
|
||||
await FFmpegPathMustExist(dbContext);
|
||||
private static async Task<Validation<BaseError, string>> Validate(TvContext dbContext, CancellationToken cancellationToken) =>
|
||||
await FFmpegPathMustExist(dbContext, cancellationToken);
|
||||
|
||||
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext) =>
|
||||
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath)
|
||||
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext, CancellationToken cancellationToken) =>
|
||||
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
|
||||
.FilterT(File.Exists)
|
||||
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
|
||||
}
|
||||
|
||||
@@ -17,5 +17,7 @@ public record CreateFillerPreset(
|
||||
int? MediaItemId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
string Expression
|
||||
int? PlaylistId,
|
||||
string Expression,
|
||||
bool UseChaptersAsMediaItems
|
||||
) : IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -37,7 +37,10 @@ public class CreateFillerPresetHandler : IRequestHandler<CreateFillerPreset, Eit
|
||||
MediaItemId = request.MediaItemId,
|
||||
MultiCollectionId = request.MultiCollectionId,
|
||||
SmartCollectionId = request.SmartCollectionId,
|
||||
Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null
|
||||
PlaylistId = request.PlaylistId,
|
||||
Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null,
|
||||
UseChaptersAsMediaItems =
|
||||
request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems
|
||||
};
|
||||
|
||||
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);
|
||||
|
||||
@@ -18,7 +18,7 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => DoDeletion(dbContext, ps));
|
||||
}
|
||||
|
||||
@@ -30,8 +30,9 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
|
||||
|
||||
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteFillerPreset request) =>
|
||||
DeleteFillerPreset request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FillerPresets
|
||||
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId)
|
||||
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"FillerPreset {request.FillerPresetId} does not exist."));
|
||||
}
|
||||
|
||||
@@ -18,5 +18,7 @@ public record UpdateFillerPreset(
|
||||
int? MediaItemId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
string Expression
|
||||
int? PlaylistId,
|
||||
string Expression,
|
||||
bool UseChaptersAsMediaItems
|
||||
) : IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -16,7 +16,10 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
|
||||
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(
|
||||
dbContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
|
||||
}
|
||||
|
||||
@@ -37,7 +40,10 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
|
||||
existing.MediaItemId = request.MediaItemId;
|
||||
existing.MultiCollectionId = request.MultiCollectionId;
|
||||
existing.SmartCollectionId = request.SmartCollectionId;
|
||||
existing.PlaylistId = request.PlaylistId;
|
||||
existing.Expression = request.FillerKind is FillerKind.MidRoll ? request.Expression : null;
|
||||
existing.UseChaptersAsMediaItems =
|
||||
request.FillerKind is not FillerKind.Fallback && request.UseChaptersAsMediaItems;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
@@ -46,8 +52,9 @@ public class UpdateFillerPresetHandler : IRequestHandler<UpdateFillerPreset, Eit
|
||||
|
||||
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFillerPreset request) =>
|
||||
UpdateFillerPreset request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FillerPresets
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id)
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("FillerPreset does not exist"));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
@@ -17,4 +18,6 @@ public record FillerPresetViewModel(
|
||||
int? MediaItemId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
string Expression);
|
||||
PlaylistViewModel Playlist,
|
||||
string Expression,
|
||||
bool UseChaptersAsMediaItems);
|
||||
|
||||
@@ -19,5 +19,9 @@ internal static class Mapper
|
||||
fillerPreset.MediaItemId,
|
||||
fillerPreset.MultiCollectionId,
|
||||
fillerPreset.SmartCollectionId,
|
||||
fillerPreset.Expression);
|
||||
fillerPreset.Playlist is not null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(fillerPreset.Playlist)
|
||||
: null,
|
||||
fillerPreset.Expression,
|
||||
fillerPreset.UseChaptersAsMediaItems);
|
||||
}
|
||||
|
||||
@@ -5,20 +5,18 @@ using static ErsatzTV.Application.Filler.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public class GetFillerPresetByIdHandler : IRequestHandler<GetFillerPresetById, Option<FillerPresetViewModel>>
|
||||
public class GetFillerPresetByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetFillerPresetById, Option<FillerPresetViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetFillerPresetByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Option<FillerPresetViewModel>> Handle(
|
||||
GetFillerPresetById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.FillerPresets
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||
.AsNoTracking()
|
||||
.Include(i => i.Playlist)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id, cancellationToken)
|
||||
.MapT(ProjectToViewModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record RefreshGraphicsElements : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,89 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public class RefreshGraphicsElementsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RefreshGraphicsElementsHandler> logger)
|
||||
: IRequestHandler<RefreshGraphicsElements>
|
||||
{
|
||||
public async Task Handle(RefreshGraphicsElements request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// cleanup existing elements
|
||||
List<GraphicsElement> allExisting = await dbContext.GraphicsElements
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (GraphicsElement existing in allExisting.Where(e => !localFileSystem.FileExists(e.Path)))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Removing graphics element that references non-existing file {File}",
|
||||
existing.Path);
|
||||
|
||||
dbContext.GraphicsElements.Remove(existing);
|
||||
}
|
||||
|
||||
// add new text elements
|
||||
var newTextPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder)
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
foreach (string path in newTextPaths)
|
||||
{
|
||||
logger.LogDebug("Adding new graphics element from file {File}", path);
|
||||
|
||||
var graphicsElement = new GraphicsElement
|
||||
{
|
||||
Path = path,
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
// add new image elements
|
||||
var newImagePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsImageTemplatesFolder)
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
foreach (string path in newImagePaths)
|
||||
{
|
||||
logger.LogDebug("Adding new graphics element from file {File}", path);
|
||||
|
||||
var graphicsElement = new GraphicsElement
|
||||
{
|
||||
Path = path,
|
||||
Kind = GraphicsElementKind.Image
|
||||
};
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
// add new subtitle elements
|
||||
var newSubtitlePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder)
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
foreach (string path in newSubtitlePaths)
|
||||
{
|
||||
logger.LogDebug("Adding new graphics element from file {File}", path);
|
||||
|
||||
var graphicsElement = new GraphicsElement
|
||||
{
|
||||
Path = path,
|
||||
Kind = GraphicsElementKind.Subtitle
|
||||
};
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record GraphicsElementViewModel(int Id, string Name);
|
||||
18
ErsatzTV.Application/Graphics/Mapper.cs
Normal file
18
ErsatzTV.Application/Graphics/Mapper.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public static class Mapper
|
||||
{
|
||||
public static GraphicsElementViewModel ProjectToViewModel(GraphicsElement graphicsElement)
|
||||
{
|
||||
string fileName = Path.GetFileName(graphicsElement.Path);
|
||||
return graphicsElement.Kind switch
|
||||
{
|
||||
GraphicsElementKind.Text => new GraphicsElementViewModel(graphicsElement.Id, $"text/{fileName}"),
|
||||
GraphicsElementKind.Image => new GraphicsElementViewModel(graphicsElement.Id, $"image/{fileName}"),
|
||||
GraphicsElementKind.Subtitle => new GraphicsElementViewModel(graphicsElement.Id, $"subtitle/{fileName}"),
|
||||
_ => new GraphicsElementViewModel(graphicsElement.Id, graphicsElement.Path)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record GetAllGraphicsElements : IRequest<List<GraphicsElementViewModel>>;
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Graphics.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public class GetAllGraphicsElementsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllGraphicsElements, List<GraphicsElementViewModel>>
|
||||
{
|
||||
public async Task<List<GraphicsElementViewModel>> Handle(
|
||||
GetAllGraphicsElements request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.GraphicsElements
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class UpdateHDHRTunerCountHandler : IRequestHandler<UpdateHDHRTunerCount,
|
||||
Validate(request)
|
||||
.MapT(_ => _configElementRepository.Upsert(
|
||||
ConfigElementKey.HDHRTunerCount,
|
||||
request.TunerCount.ToString(CultureInfo.InvariantCulture)))
|
||||
request.TunerCount.ToString(CultureInfo.InvariantCulture), cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>
|
||||
|
||||
@@ -11,6 +11,6 @@ public class GetHDHRTunerCountHandler : IRequestHandler<GetHDHRTunerCount, int>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<int> Handle(GetHDHRTunerCount request, CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.HDHRTunerCount)
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.HDHRTunerCount, cancellationToken)
|
||||
.Map(result => result.IfNone(2));
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ public class GetHDHRUUIDHandler : IRequestHandler<GetHDHRUUID, Guid>
|
||||
|
||||
public async Task<Guid> Handle(GetHDHRUUID request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeGuid = await _configElementRepository.GetValue<Guid>(ConfigElementKey.HDHRUUID);
|
||||
Option<Guid> maybeGuid = await _configElementRepository.GetValue<Guid>(ConfigElementKey.HDHRUUID, cancellationToken);
|
||||
return await maybeGuid.IfNoneAsync(async () =>
|
||||
{
|
||||
var guid = Guid.NewGuid();
|
||||
await _configElementRepository.Upsert(ConfigElementKey.HDHRUUID, guid);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.HDHRUUID, guid, cancellationToken);
|
||||
return guid;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public class UpdateImageFolderDurationHandler(IDbContextFactory<TvContext> dbCon
|
||||
else
|
||||
{
|
||||
Option<ImageFolderDuration> maybeExisting = await dbContext.ImageFolderDurations
|
||||
.SelectOneAsync(ifd => ifd.LibraryFolderId, ifd => ifd.LibraryFolderId == request.LibraryFolderId);
|
||||
.SelectOneAsync(ifd => ifd.LibraryFolderId, ifd => ifd.LibraryFolderId == request.LibraryFolderId, cancellationToken);
|
||||
|
||||
if (maybeExisting.IsNone)
|
||||
{
|
||||
@@ -53,7 +53,7 @@ public class UpdateImageFolderDurationHandler(IDbContextFactory<TvContext> dbCon
|
||||
Option<LibraryFolder> maybeFolder = await dbContext.LibraryFolders
|
||||
.AsNoTracking()
|
||||
.Include(lf => lf.ImageFolderDuration)
|
||||
.SelectOneAsync(lf => lf.Id, lf => lf.Id == request.LibraryFolderId);
|
||||
.SelectOneAsync(lf => lf.Id, lf => lf.Id == request.LibraryFolderId, cancellationToken);
|
||||
|
||||
var queue = new Queue<FolderWithParentDuration>();
|
||||
foreach (LibraryFolder libraryFolder in maybeFolder)
|
||||
@@ -67,7 +67,7 @@ public class UpdateImageFolderDurationHandler(IDbContextFactory<TvContext> dbCon
|
||||
Option<LibraryFolder> maybeParent = await dbContext.LibraryFolders
|
||||
.AsNoTracking()
|
||||
.Include(lf => lf.ImageFolderDuration)
|
||||
.SelectOneAsync(lf => lf.Id, lf => lf.Id == currentFolder.ParentId);
|
||||
.SelectOneAsync(lf => lf.Id, lf => lf.Id == currentFolder.ParentId, cancellationToken);
|
||||
|
||||
if (maybeParent.IsNone)
|
||||
{
|
||||
|
||||
@@ -30,15 +30,16 @@ public class
|
||||
GetCachedImagePath request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate();
|
||||
Validation<BaseError, string> validation = await Validate(cancellationToken);
|
||||
return await validation.Match(
|
||||
ffmpegPath => Handle(ffmpegPath, request),
|
||||
ffmpegPath => Handle(ffmpegPath, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, CachedImagePathViewModel>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, CachedImagePathViewModel>> Handle(
|
||||
string ffmpegPath,
|
||||
GetCachedImagePath request)
|
||||
GetCachedImagePath request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -75,7 +76,7 @@ public class
|
||||
withExtension,
|
||||
request.MaxHeight.Value);
|
||||
|
||||
CommandResult resize = await process.ExecuteAsync();
|
||||
CommandResult resize = await process.ExecuteAsync(cancellationToken);
|
||||
|
||||
if (resize.ExitCode != 0)
|
||||
{
|
||||
@@ -106,11 +107,11 @@ public class
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, string>> Validate() =>
|
||||
await ValidateFFmpegPath();
|
||||
private async Task<Validation<BaseError, string>> Validate(CancellationToken cancellationToken) =>
|
||||
await ValidateFFmpegPath(cancellationToken);
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
|
||||
private Task<Validation<BaseError, string>> ValidateFFmpegPath(CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
|
||||
.FilterT(File.Exists)
|
||||
.Map(ffmpegPath => ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
@@ -42,10 +42,11 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeJellyfinCollections request)
|
||||
SynchronizeJellyfinCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.JellyfinMediaSources
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId)
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId, cancellationToken)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
|
||||
@@ -39,7 +39,7 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
@@ -78,10 +78,11 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeJellyfinLibraryById request)
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.JellyfinLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId)
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId, cancellationToken)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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 Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinShowById>,
|
||||
IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>
|
||||
{
|
||||
public CallJellyfinShowScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>.Handle(
|
||||
SynchronizeJellyfinShowById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
SynchronizeJellyfinShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
SynchronizeJellyfinShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-jellyfin-show",
|
||||
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeJellyfinShowById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DateTimeOffset.MinValue);
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
SynchronizeJellyfinShowById request) =>
|
||||
true;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public class
|
||||
SynchronizeJellyfinLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(SynchronizeLibraries)
|
||||
.MapT(p => SynchronizeLibraries(p, cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinLibraries request) =>
|
||||
@@ -66,7 +66,9 @@ public class
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters)
|
||||
private async Task<Unit> SynchronizeLibraries(
|
||||
ConnectionParameters connectionParameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, List<JellyfinLibrary>> maybeLibraries = await _jellyfinApiClient.GetLibraries(
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
@@ -93,7 +95,8 @@ public class
|
||||
connectionParameters.JellyfinMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate);
|
||||
toUpdate,
|
||||
cancellationToken);
|
||||
if (ids.Count != 0)
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
@@ -23,7 +23,7 @@ public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<Synchroniz
|
||||
SynchronizeJellyfinMediaSources request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
|
||||
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin(cancellationToken);
|
||||
foreach (JellyfinMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinShowById(int JellyfinLibraryId, int ShowId, bool DeepScan)
|
||||
: IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest;
|
||||
@@ -23,7 +23,7 @@ public class
|
||||
UpdateJellyfinLibraryPreferences request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
var toDisable = request.Preferences.Filter(p => !p.ShouldSyncItems).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisableJellyfinLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -15,5 +15,5 @@ public class
|
||||
public Task<List<JellyfinMediaSourceViewModel>> Handle(
|
||||
GetAllJellyfinMediaSources request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetAllJellyfin().Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
_mediaSourceRepository.GetAllJellyfin(cancellationToken).Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public class GetJellyfinConnectionParametersHandler : IRequestHandler<GetJellyfi
|
||||
}
|
||||
|
||||
Either<BaseError, JellyfinConnectionParametersViewModel> maybeParameters =
|
||||
await Validate()
|
||||
await Validate(cancellationToken)
|
||||
.MapT(cp => new JellyfinConnectionParametersViewModel(cp.ActiveConnection.Address))
|
||||
.Map(v => v.ToEither<JellyfinConnectionParametersViewModel>());
|
||||
|
||||
@@ -42,12 +42,13 @@ public class GetJellyfinConnectionParametersHandler : IRequestHandler<GetJellyfi
|
||||
error => error);
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
||||
JellyfinMediaSourceMustExist()
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(CancellationToken cancellationToken) =>
|
||||
JellyfinMediaSourceMustExist(cancellationToken)
|
||||
.BindT(MediaSourceMustHaveActiveConnection);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist() =>
|
||||
_mediaSourceRepository.GetAllJellyfin().Map(list => list.HeadOrNone())
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetAllJellyfin(cancellationToken).Map(list => list.HeadOrNone())
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
"Jellyfin media source does not exist."));
|
||||
|
||||
|
||||
@@ -169,20 +169,23 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract Task<DateTimeOffset> GetLastScan(TvContext dbContext, TRequest request);
|
||||
protected abstract Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
TRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
|
||||
|
||||
protected async Task<Validation<BaseError, string>> Validate(TRequest request)
|
||||
protected async Task<Validation<BaseError, string>> Validate(TRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
int libraryRefreshInterval = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
|
||||
.IfNoneAsync(0);
|
||||
|
||||
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
DateTimeOffset lastScan = await GetLastScan(dbContext, request);
|
||||
DateTimeOffset lastScan = await GetLastScan(dbContext, request, cancellationToken);
|
||||
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
|
||||
{
|
||||
return new ScanIsNotRequired();
|
||||
|
||||
@@ -25,7 +25,10 @@ public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await LocalLibraryMustExist(dbContext, request);
|
||||
Validation<BaseError, LocalLibrary> validation = await LocalLibraryMustExist(
|
||||
dbContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
return await validation.Apply(localLibrary => DoDeletion(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
@@ -77,8 +80,9 @@ public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteLocalLibrary request) =>
|
||||
DeleteLocalLibrary request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.LocalLibraries
|
||||
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.LocalLibraryId)
|
||||
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.LocalLibraryId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"Local library {request.LocalLibraryId} does not exist."));
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
public interface ILocalLibraryRequest
|
||||
{
|
||||
public string Name { get; }
|
||||
string Name { get; }
|
||||
}
|
||||
|
||||
@@ -38,17 +38,17 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => MovePath(dbContext, parameters));
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(parameters => MovePath(dbContext, parameters, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
|
||||
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
LibraryPath path = parameters.LibraryPath;
|
||||
LocalLibrary newLibrary = parameters.Library;
|
||||
|
||||
path.LibraryId = newLibrary.Id;
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
||||
{
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT MediaItem.Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
|
||||
@@ -57,14 +57,14 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
|
||||
foreach (int id in ids)
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id);
|
||||
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id, cancellationToken);
|
||||
foreach (MediaItem mediaItem in maybeMediaItem)
|
||||
{
|
||||
_logger.LogInformation("Moving item at {Path}", await GetPath(dbContext, mediaItem));
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<MediaItem> { mediaItem });
|
||||
[mediaItem]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,24 +74,28 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request) =>
|
||||
(await LibraryPathMustExist(dbContext, request), await LocalLibraryMustExist(dbContext, request))
|
||||
MoveLocalLibraryPath request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await LibraryPathMustExist(dbContext, request, cancellationToken),
|
||||
await LocalLibraryMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
|
||||
|
||||
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request) =>
|
||||
MoveLocalLibraryPath request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.LibraryPaths
|
||||
.Include(lp => lp.Library)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.LibraryPathId)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.LibraryPathId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("LibraryPath does not exist."));
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request) =>
|
||||
MoveLocalLibraryPath request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.LocalLibraries
|
||||
.Include(ll => ll.Paths)
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId)
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist"));
|
||||
|
||||
private static async Task<string> GetPath(TvContext dbContext, MediaItem mediaItem) =>
|
||||
|
||||
@@ -25,10 +25,24 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
|
||||
Option<Library> maybeLibrary = await dbContext.Libraries
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.LibraryId);
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.LibraryId, cancellationToken);
|
||||
|
||||
foreach (Library library in maybeLibrary)
|
||||
{
|
||||
bool shouldSyncItems = library switch
|
||||
{
|
||||
PlexLibrary plexLibrary => plexLibrary.ShouldSyncItems,
|
||||
JellyfinLibrary jellyfinLibrary => jellyfinLibrary.ShouldSyncItems,
|
||||
EmbyLibrary embyLibrary => embyLibrary.ShouldSyncItems,
|
||||
_ => true
|
||||
};
|
||||
|
||||
if (!shouldSyncItems)
|
||||
{
|
||||
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (locker.LockLibrary(library.Id))
|
||||
{
|
||||
logger.LogDebug("Queued library scan for library id {Id}", library.Id);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan) : IRequest<bool>;
|
||||
@@ -0,0 +1,94 @@
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public class QueueShowScanByLibraryIdHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IEntityLocker locker,
|
||||
IMediator mediator,
|
||||
ILogger<QueueShowScanByLibraryIdHandler> logger)
|
||||
: IRequestHandler<QueueShowScanByLibraryId, bool>
|
||||
{
|
||||
public async Task<bool> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Library> maybeLibrary = await dbContext.Libraries
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.LibraryId, cancellationToken);
|
||||
|
||||
foreach (Library library in maybeLibrary)
|
||||
{
|
||||
bool shouldSyncItems = library switch
|
||||
{
|
||||
PlexLibrary plexLibrary => plexLibrary.ShouldSyncItems,
|
||||
JellyfinLibrary jellyfinLibrary => jellyfinLibrary.ShouldSyncItems,
|
||||
EmbyLibrary embyLibrary => embyLibrary.ShouldSyncItems,
|
||||
_ => true
|
||||
};
|
||||
|
||||
if (!shouldSyncItems)
|
||||
{
|
||||
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if library is already being scanned - return false if locked
|
||||
if (!locker.LockLibrary(library.Id))
|
||||
{
|
||||
logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Queued show scan for library id {Id}, show: {ShowTitle}, deepScan: {DeepScan}",
|
||||
library.Id,
|
||||
request.ShowTitle,
|
||||
request.DeepScan);
|
||||
|
||||
try
|
||||
{
|
||||
switch (library)
|
||||
{
|
||||
case PlexLibrary:
|
||||
Either<BaseError, string> plexResult = await mediator.Send(
|
||||
new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
return plexResult.IsRight;
|
||||
case JellyfinLibrary:
|
||||
Either<BaseError, string> jellyfinResult = await mediator.Send(
|
||||
new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
return jellyfinResult.IsRight;
|
||||
case EmbyLibrary:
|
||||
Either<BaseError, string> embyResult = await mediator.Send(
|
||||
new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
return embyResult.IsRight;
|
||||
case LocalLibrary:
|
||||
logger.LogWarning("Single show scanning is not supported for local libraries");
|
||||
return false;
|
||||
default:
|
||||
logger.LogWarning("Unknown library type for library {Id}", library.Id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always unlock the library when we're done
|
||||
locker.UnlockLibrary(library.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
}
|
||||
|
||||
@@ -53,45 +53,51 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
.Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path)))
|
||||
.ToList();
|
||||
|
||||
var toRemoveIds = toRemove.Map(lp => lp.Id).ToList();
|
||||
var toRemoveIds = toRemove.Map(lp => lp.Id).ToHashSet();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
var changeCount = 0;
|
||||
|
||||
// save item ids first; will need to remove from search index
|
||||
List<int> itemsToRemove = await dbContext.MediaItems
|
||||
.AsNoTracking()
|
||||
.Filter(mi => toRemoveIds.Contains(mi.LibraryPathId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
|
||||
changeCount += await dbContext.Connection.ExecuteAsync(
|
||||
"DELETE FROM MediaItem WHERE LibraryPathId IN @Ids",
|
||||
new { Ids = toRemoveIds });
|
||||
|
||||
// delete all library folders (children first)
|
||||
IOrderedQueryable<LibraryFolder> orderedFolders = dbContext.LibraryFolders
|
||||
.AsNoTracking()
|
||||
.Filter(lf => toRemoveIds.Contains(lf.LibraryPathId))
|
||||
.OrderByDescending(lp => lp.Path.Length);
|
||||
|
||||
foreach (LibraryFolder folder in orderedFolders)
|
||||
{
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
changeCount += await dbContext.Connection.ExecuteAsync(
|
||||
"DELETE FROM LibraryFolder WHERE Id = @LibraryFolderId",
|
||||
new { LibraryFolderId = folder.Id });
|
||||
}
|
||||
|
||||
await dbContext.LibraryPaths
|
||||
changeCount += await dbContext.LibraryPaths
|
||||
.Filter(lp => toRemoveIds.Contains(lp.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
existing.Paths.AddRange(toAdd);
|
||||
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
List<int> itemsToRemove = await dbContext.MediaItems
|
||||
.AsNoTracking()
|
||||
.Filter(mi => toRemoveIds.Contains(mi.LibraryPathId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
changeCount += await dbContext.SaveChangesAsync();
|
||||
|
||||
if (changeCount > 0)
|
||||
{
|
||||
await _searchIndex.RemoveItems(itemsToRemove);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
if ((toAdd.Count > 0 || toRemove.Count > 0) && _entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
if (_entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
}
|
||||
|
||||
return ProjectToViewModel(existing);
|
||||
@@ -99,18 +105,20 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateLocalLibrary request) =>
|
||||
LocalLibraryMustExist(dbContext, request)
|
||||
UpdateLocalLibrary request,
|
||||
CancellationToken cancellationToken) =>
|
||||
LocalLibraryMustExist(dbContext, request, cancellationToken)
|
||||
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
|
||||
.BindT(parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
|
||||
.MapT(_ => parameters));
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateLocalLibrary request) =>
|
||||
UpdateLocalLibrary request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.LocalLibraries
|
||||
.Include(ll => ll.Paths)
|
||||
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.Id)
|
||||
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.Id, cancellationToken)
|
||||
.MapT(existing =>
|
||||
{
|
||||
var incoming = new LocalLibrary
|
||||
|
||||
@@ -25,7 +25,7 @@ public class DeleteOrphanedSubtitlesHandler : IRequestHandler<DeleteOrphanedSubt
|
||||
WHERE S.ArtistMetadataId IS NULL AND S.EpisodeMetadataId IS NULL
|
||||
AND S.MovieMetadataId IS NULL AND S.MusicVideoMetadataId IS NULL
|
||||
AND S.OtherVideoMetadataId IS NULL AND S.SeasonMetadataId IS NULL
|
||||
AND S.ShowMetadataId IS NULL AND s.SongMetadataId IS NULL");
|
||||
AND S.ShowMetadataId IS NULL AND S.SongMetadataId IS NULL");
|
||||
|
||||
foreach (int id in toDelete)
|
||||
{
|
||||
|
||||
@@ -26,7 +26,13 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
|
||||
EmptyTrash request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult result = await _searchIndex.Search(_client, "state:FileNotFound", string.Empty, 0, 10_000);
|
||||
SearchResult result = await _searchIndex.Search(
|
||||
_client,
|
||||
"state:FileNotFound",
|
||||
string.Empty,
|
||||
0,
|
||||
10_000,
|
||||
cancellationToken);
|
||||
var ids = result.Items.Map(i => i.Id).ToList();
|
||||
|
||||
// ElasticSearch remove items may fail, so do that first
|
||||
|
||||
@@ -34,7 +34,7 @@ internal static class Mapper
|
||||
GetSeasonName(season.SeasonNumber),
|
||||
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby))
|
||||
.IfNone(string.Empty),
|
||||
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString(CultureInfo.InvariantCulture),
|
||||
season.SeasonNumber == 0 ? "S" : new string(season.SeasonNumber.ToString(CultureInfo.InvariantCulture).Take(20).ToArray()),
|
||||
season.State);
|
||||
|
||||
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
|
||||
|
||||
@@ -28,10 +28,10 @@ public class GetCollectionCardsHandler :
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin(cancellationToken)
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby(cancellationToken)
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
return await dbContext.Collections
|
||||
@@ -111,7 +111,7 @@ public class GetCollectionCardsHandler :
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as RemoteStream).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id, cancellationToken)
|
||||
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
|
||||
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public class GetMusicVideoCardsHandler : IRequestHandler<GetMusicVideoCards, Mus
|
||||
_plexPathReplacementService,
|
||||
_jellyfinPathReplacementService,
|
||||
_embyPathReplacementService,
|
||||
cancellationToken,
|
||||
false);
|
||||
|
||||
results.Add(ProjectToViewModel(musicVideoMetadata, localPath));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user