Compare commits
288 Commits
v0.6.2-bet
...
v0.8.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c128f72a54 | ||
|
|
4af2d7aa61 | ||
|
|
20a6727158 | ||
|
|
52e1874426 | ||
|
|
015f5e9798 | ||
|
|
1fc461e476 | ||
|
|
85792f0811 | ||
|
|
0f91a43e3f | ||
|
|
7a25996ab4 | ||
|
|
6985826072 | ||
|
|
52482ef2fb | ||
|
|
c148f2eb11 | ||
|
|
d490cc6f4b | ||
|
|
99bd827bd9 | ||
|
|
e8cbcc935f | ||
|
|
a2acfe4d80 | ||
|
|
5da2bdbab4 | ||
|
|
66607b95bb | ||
|
|
81a6251f65 | ||
|
|
c554d83d60 | ||
|
|
875010bbf4 | ||
|
|
c5692ef5f1 | ||
|
|
147ab6143d | ||
|
|
aca441074e | ||
|
|
ef6adf9cbb | ||
|
|
ddb7e1887f | ||
|
|
4997699b4d | ||
|
|
c27b906cd5 | ||
|
|
bec3cb864d | ||
|
|
03df2a6c8a | ||
|
|
6142dcf153 | ||
|
|
b287f791e6 | ||
|
|
2ccba9e476 | ||
|
|
e215807e56 | ||
|
|
b0333e89cd | ||
|
|
bc240a40e0 | ||
|
|
ed816e4b06 | ||
|
|
7c0f26ed3e | ||
|
|
a54e37a648 | ||
|
|
c0656114b8 | ||
|
|
7628ec7921 | ||
|
|
30850329f3 | ||
|
|
6bb1c4299f | ||
|
|
73c6758537 | ||
|
|
ef1400d3f8 | ||
|
|
494142f026 | ||
|
|
b89deffda3 | ||
|
|
ab55978732 | ||
|
|
b8dcd26e3a | ||
|
|
24f7544c9f | ||
|
|
afb2caa95d | ||
|
|
a684dcced9 | ||
|
|
cf1552910a | ||
|
|
e53e2b36cf | ||
|
|
793e85f889 | ||
|
|
126304bb8a | ||
|
|
7b3b9b4aad | ||
|
|
cf3b8d90e3 | ||
|
|
109b244676 | ||
|
|
9f42333465 | ||
|
|
c9141b0d86 | ||
|
|
d2c4a58528 | ||
|
|
e93d678b97 | ||
|
|
307940d732 | ||
|
|
721f0df82a | ||
|
|
aa87abc53d | ||
|
|
83d4aa0cb1 | ||
|
|
46034aff54 | ||
|
|
3e447ac7e4 | ||
|
|
bda27faaa3 | ||
|
|
80d89a2530 | ||
|
|
e849ef5dfa | ||
|
|
a26ecb91b8 | ||
|
|
2853e13edc | ||
|
|
9ba0b844a1 | ||
|
|
fdab54a055 | ||
|
|
7e0801119e | ||
|
|
b2f7bcaf1e | ||
|
|
71b8be37da | ||
|
|
f7d19e3747 | ||
|
|
17dcbfc344 | ||
|
|
78745de0ca | ||
|
|
35445e2b3d | ||
|
|
bd2f0f6236 | ||
|
|
4c67965b50 | ||
|
|
234e93349b | ||
|
|
e7e20de502 | ||
|
|
dfc36b4581 | ||
|
|
c56e2526c4 | ||
|
|
8ff6bf652c | ||
|
|
a386fe9ba1 | ||
|
|
4d84fc242b | ||
|
|
40e79a3a14 | ||
|
|
c653bb32a7 | ||
|
|
b032e70d7e | ||
|
|
074816be50 | ||
|
|
3fafd5192f | ||
|
|
1d63197b56 | ||
|
|
b2c57e7407 | ||
|
|
581aa51792 | ||
|
|
4d57ece30d | ||
|
|
eddbf07b11 | ||
|
|
450ea063b4 | ||
|
|
f320d84874 | ||
|
|
c832c8e860 | ||
|
|
e5ef8eaf72 | ||
|
|
6db71f525d | ||
|
|
3ab66ef12a | ||
|
|
018f759fa4 | ||
|
|
1afff11063 | ||
|
|
7e3436e68f | ||
|
|
b751f1054b | ||
|
|
900e9e75f3 | ||
|
|
62c28d9f51 | ||
|
|
132ca99f94 | ||
|
|
c309ab430e | ||
|
|
13e21bbcce | ||
|
|
0eb36f0ce1 | ||
|
|
6429f0f064 | ||
|
|
7412ac6fc9 | ||
|
|
e58e3c786d | ||
|
|
93fc1e4eb4 | ||
|
|
cacde26796 | ||
|
|
0a3db92c60 | ||
|
|
8bb0cd5ab5 | ||
|
|
e497dc4e36 | ||
|
|
2689a67eb8 | ||
|
|
3d821043bb | ||
|
|
e69c58e615 | ||
|
|
a21b6f9f4e | ||
|
|
99b8038852 | ||
|
|
ef8ca9f8c6 | ||
|
|
d9186df157 | ||
|
|
aca6bfb0bb | ||
|
|
587fc3a98f | ||
|
|
ab1c67e60e | ||
|
|
e271f43066 | ||
|
|
6bf8feb26e | ||
|
|
ffd66f6a21 | ||
|
|
3b135df4c1 | ||
|
|
4369d04940 | ||
|
|
faaa78fed7 | ||
|
|
6bea1660ea | ||
|
|
8d46676c25 | ||
|
|
4c75e638a2 | ||
|
|
dd73a3803a | ||
|
|
f6c345d7cf | ||
|
|
585b56a668 | ||
|
|
f18f3b4f35 | ||
|
|
eb7871a048 | ||
|
|
000fc78fd3 | ||
|
|
ba676ef956 | ||
|
|
36ea88e2d6 | ||
|
|
5237e6fa50 | ||
|
|
99bde1819c | ||
|
|
f5d7ec2890 | ||
|
|
13c65435d3 | ||
|
|
315420f1a5 | ||
|
|
ab7051f075 | ||
|
|
a43e5bbe9d | ||
|
|
b7bd4541b1 | ||
|
|
648f25e9cc | ||
|
|
ccbe85a46a | ||
|
|
d168d79fe0 | ||
|
|
d37dde2477 | ||
|
|
8e13b07c84 | ||
|
|
927e7724f0 | ||
|
|
6558c5bd69 | ||
|
|
5f7efbb69c | ||
|
|
b79795af50 | ||
|
|
9479806cb0 | ||
|
|
6e49ea78ec | ||
|
|
7b1edd9c54 | ||
|
|
aeaafd2964 | ||
|
|
622fa01602 | ||
|
|
e2b3c1ce8e | ||
|
|
6c5db650e7 | ||
|
|
731072425b | ||
|
|
0f817308a8 | ||
|
|
0fc1e15cac | ||
|
|
acf30384b7 | ||
|
|
d2040eaac9 | ||
|
|
93673fce03 | ||
|
|
d7a432068b | ||
|
|
cb9215980a | ||
|
|
a4fc1f1c6f | ||
|
|
cbbdb11938 | ||
|
|
a2274bca7b | ||
|
|
f84496b09d | ||
|
|
3abf310a3b | ||
|
|
f12e361c2e | ||
|
|
cd0f1e98cc | ||
|
|
325ef80951 | ||
|
|
9a30d7c7da | ||
|
|
25ea75b761 | ||
|
|
32edf77d35 | ||
|
|
47fbb2b1b7 | ||
|
|
e388f81e1f | ||
|
|
f0bea295c4 | ||
|
|
7439ded43d | ||
|
|
6a640d3708 | ||
|
|
776bce9087 | ||
|
|
3c499f9e97 | ||
|
|
114ff7a3e3 | ||
|
|
527cdf523c | ||
|
|
91eb8ab824 | ||
|
|
7a87fb1c2e | ||
|
|
d8cc6b4c22 | ||
|
|
c9bd94d9f8 | ||
|
|
93bf818882 | ||
|
|
723fb3848d | ||
|
|
6a213e2249 | ||
|
|
a6c5c3a317 | ||
|
|
9313d2c8eb | ||
|
|
485a874ab5 | ||
|
|
f2bc884632 | ||
|
|
39d6653f8e | ||
|
|
2ce0fcb264 | ||
|
|
8bf5e18ae5 | ||
|
|
88f4d8074a | ||
|
|
f5aa2fcac8 | ||
|
|
6f892bea6b | ||
|
|
cbf0c9c988 | ||
|
|
393c67213d | ||
|
|
f69de9f071 | ||
|
|
2e400c0d22 | ||
|
|
6035c10550 | ||
|
|
555b156154 | ||
|
|
a0ea2e8910 | ||
|
|
734ca39cbd | ||
|
|
e0e5cfada5 | ||
|
|
7e0c43bc46 | ||
|
|
be1125a9ab | ||
|
|
d21c985a77 | ||
|
|
28f2b9b27e | ||
|
|
9b185e19e9 | ||
|
|
27b923b462 | ||
|
|
357dfee050 | ||
|
|
7f4004c228 | ||
|
|
9b8dc0ed80 | ||
|
|
3cc1286271 | ||
|
|
df281758b7 | ||
|
|
25273c18c8 | ||
|
|
f1be945423 | ||
|
|
9a4f772f53 | ||
|
|
d669e8114b | ||
|
|
3972e3603b | ||
|
|
acc22fcb62 | ||
|
|
2df360d7fb | ||
|
|
46331ed2c6 | ||
|
|
3aee3b0515 | ||
|
|
72c45692b2 | ||
|
|
8edf71ca55 | ||
|
|
612b9e6524 | ||
|
|
7aff65f07b | ||
|
|
5d350fcfad | ||
|
|
5546ad204c | ||
|
|
d66efa0a1d | ||
|
|
36d3d38530 | ||
|
|
8e79141860 | ||
|
|
9b3545f7ca | ||
|
|
56db20faa0 | ||
|
|
b0bd4c9fed | ||
|
|
ba079452e2 | ||
|
|
f0f2b3da4b | ||
|
|
866049543c | ||
|
|
40ed4b8b0e | ||
|
|
b43d08ca67 | ||
|
|
5e7e386108 | ||
|
|
4176df9940 | ||
|
|
de2ef959fe | ||
|
|
b53cfebac1 | ||
|
|
6895b9cc6b | ||
|
|
c60d6e46f1 | ||
|
|
c66d190174 | ||
|
|
5e8da591be | ||
|
|
9c02a6738b | ||
|
|
5ed0184bca | ||
|
|
ae64ca4a93 | ||
|
|
c47099895e | ||
|
|
a2529febba | ||
|
|
521e0ba8b3 | ||
|
|
ee0efac9be | ||
|
|
bfe7635489 | ||
|
|
aa1735f024 | ||
|
|
8deae983c7 | ||
|
|
f349646703 | ||
|
|
5003e80500 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2022.1.0",
|
||||
"version": "2023.1.1",
|
||||
"commands": [
|
||||
"jb"
|
||||
]
|
||||
|
||||
48
.github/workflows/artifacts.yml
vendored
48
.github/workflows/artifacts.yml
vendored
@@ -33,10 +33,10 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
- os: macos-11
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-latest
|
||||
- os: macos-11
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
steps:
|
||||
@@ -47,9 +47,9 @@ jobs:
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v2
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
node-version: '14'
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.4.5
|
||||
uses: bahmutov/npm-install@v1.8.28
|
||||
with:
|
||||
working-directory: ErsatzTV/client-app
|
||||
|
||||
@@ -81,7 +81,10 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Bundle
|
||||
shell: bash
|
||||
@@ -108,15 +111,16 @@ jobs:
|
||||
--icon "ErsatzTV.app" 200 190 \
|
||||
--hide-extension "ErsatzTV.app" \
|
||||
--app-drop-link 600 185 \
|
||||
--skip-jenkins \
|
||||
"ErsatzTV.dmg" \
|
||||
"ErsatzTV.app/"
|
||||
|
||||
- name: Notarize
|
||||
shell: bash
|
||||
run: |
|
||||
curl -o gon.zip -L -s "https://github.com/mitchellh/gon/releases/latest/download/gon_macos.zip"
|
||||
unzip -o -q gon.zip
|
||||
./gon -log-level=debug -log-json ./gon.json
|
||||
brew tap mitchellh/gon
|
||||
brew install mitchellh/gon/gon
|
||||
gon -log-level=debug -log-json ./gon.json
|
||||
env:
|
||||
AC_USERNAME: ${{ secrets.ac_username }}
|
||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
||||
@@ -130,6 +134,7 @@ jobs:
|
||||
|
||||
- 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 }}
|
||||
@@ -172,17 +177,23 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v2
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.4.5
|
||||
uses: bahmutov/npm-install@v1.8.28
|
||||
with:
|
||||
working-directory: ErsatzTV/client-app
|
||||
|
||||
@@ -192,12 +203,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target }}"
|
||||
|
||||
- uses: suisei-cn/actions-download-file@v1
|
||||
- uses: suisei-cn/actions-download-file@v1.3.0
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
id: downloadffmpeg
|
||||
name: Download ffmpeg
|
||||
with:
|
||||
url: "https://github.com/GyanD/codexffmpeg/releases/download/5.0/ffmpeg-5.0-full_build.7z"
|
||||
url: "https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.7z"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Build
|
||||
@@ -208,11 +219,15 @@ jobs:
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
|
||||
# Build everything
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
# Build Windows launcher
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
dotnet publish ErsatzTV-Windows/ErsatzTV-Windows.csproj --framework net6.0-windows --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
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
|
||||
@@ -236,6 +251,7 @@ jobs:
|
||||
|
||||
- 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 }}
|
||||
|
||||
3
.github/workflows/docs.yml
vendored
3
.github/workflows/docs.yml
vendored
@@ -3,6 +3,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docs/**
|
||||
- mkdocs.yml
|
||||
jobs:
|
||||
build:
|
||||
name: Deploy docs
|
||||
|
||||
78
.github/workflows/pr.yml
vendored
78
.github/workflows/pr.yml
vendored
@@ -2,20 +2,21 @@
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
build_and_test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ windows-latest, ubuntu-latest, macos-latest ]
|
||||
build_and_test_windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v2
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -23,6 +24,67 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
|
||||
- name: Build Windows
|
||||
run: |
|
||||
cd ErsatzTV-Windows
|
||||
cargo build --release --all-features
|
||||
build_and_test_linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-11
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
|
||||
462
CHANGELOG.md
462
CHANGELOG.md
@@ -5,6 +5,446 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.0-beta] - 2023-06-23
|
||||
### Added
|
||||
- Disable playout buttons and show spinning indicator when a playout is being modified (built/extended, or subtitles are being extracted)
|
||||
- Automatically reload playout details table when playout build is complete
|
||||
- Add `Discard To Fill Attempts` setting to duration playout mode
|
||||
- This setting only has an effect when it's configured to be greater than zero and when using `Shuffle` or `Random` playback order
|
||||
- When the current item is longer than the remaining duration, it will be discarded and ETV will try to fit the next item in the collection, up to the configured number of times
|
||||
- When the remaining duration is shorter than all items in the collection, the normal filler logic will be used
|
||||
- Add `Finish` column to playout detail table
|
||||
|
||||
### Fixed
|
||||
- Skip checking for subtitles to extract when subtitles are not enabled on a channel/schedule item
|
||||
- Properly scale subtitles when using hardware acceleration
|
||||
- Fix color normalization of content with missing color metadata when using NVIDIA acceleration
|
||||
- `VAAPI`: explicitly use `CQP` rate control mode when it's the only compatible mode
|
||||
- Fix scaling anamorphic Emby content that Emby claims is not anamorphic
|
||||
|
||||
### Changed
|
||||
- `HLS Direct` streaming mode
|
||||
- Use `MPEG-TS` container/output format by default to maintain v0.7.8 compatibility
|
||||
- `MP4` and `MKV` container/output format can still be configured in `Settings`
|
||||
- Improve `MP4` compatibility with certain content
|
||||
- For `Pad` and `Duration` filler - prioritize filling the configured pad/duration
|
||||
- This will skip filler that is too long in an attempt to avoid unscheduled time
|
||||
- You may see the same filler more often, which means you may want to add more filler to your library so ETV has more options
|
||||
- Update ffmpeg, libraries and drivers in all docker images
|
||||
|
||||
## [0.7.9-beta] - 2023-06-10
|
||||
### Added
|
||||
- Synchronize actor metadata from Jellyfin and Emby television libraries
|
||||
- New libraries and new episodes will get actor data automatically
|
||||
- Existing libraries can deep scan (one time) to retrieve actor data for existing episodes
|
||||
- `HLS Direct` streaming mode
|
||||
- Use `MP4` container/output format by default, with new global option to use `MKV` container/output format
|
||||
- `MP4` output format: stream copy dvd subtitles
|
||||
- `MKV` output format: stream copy any embedded subtitles
|
||||
|
||||
### Fixed
|
||||
- Fix extracting embedded text subtitles that had been incompletely extracted in the past
|
||||
- Fix fallback filler looping by forcing software mode for this content
|
||||
- Other content will still use hardware acceleration as configured
|
||||
- Hardware-accelerated fallback filler may be re-enabled in the future
|
||||
- Fix playout building when shuffle in order is used with a single media item
|
||||
- Fix pgs subtitle burn in from media server libraries
|
||||
- Fix subtitle and watermark overlays with RadeonSI VAAPI driver
|
||||
- Fix NVIDIA pipeline to use hardware-accelerated decoder with 8-bit h264 content
|
||||
|
||||
### Changed
|
||||
- Timeout playout builds after 2 minutes; this should prevent playout bugs from blocking other functionality
|
||||
|
||||
## [0.7.8-beta] - 2023-04-29
|
||||
### Added
|
||||
- Add `Season, Episode` playback order
|
||||
- This is currently *only* available when a show is added directly to a schedule
|
||||
- This will ignore release date and sort exclusively by season number and then by episode number
|
||||
- Add `Show Media Info` button to movie and episode detail pages for troubleshooting
|
||||
|
||||
### Fixed
|
||||
- Limit `HLS Direct` streams to realtime speed
|
||||
- Fix `Reset Playout` button to use worker thread instead of UI thread
|
||||
- This fixes potential UI hangs and database concurrency bugs
|
||||
- Maintain watermark alpha channel (built-in transparency) using QSV acceleration
|
||||
- Properly extract and burn in embedded text subtitles using Jellyfin, Emby and Plex libraries
|
||||
- Fix bug where deleting a channel would not remove its data from XMLTV
|
||||
- Fix colorspace filter for some files with invalid color metadata
|
||||
- Fix playback of external subtitles on Windows
|
||||
- Fix vobsub subtitle burn in from media server libraries
|
||||
|
||||
### Changed
|
||||
- Remove duplicate items from smart collections before scheduling
|
||||
- i.e. shows no longer need to be filtered out if search results also include episodes
|
||||
- Certain multi-collection scenarios may still include duplicates across multiple collections
|
||||
- Use autocomplete fields for collection searching in schedule items editor
|
||||
- This greatly improves the editor performance
|
||||
- Ignore dot underscore files
|
||||
|
||||
## [0.7.7-beta] - 2023-04-07
|
||||
### Added
|
||||
- Use `plot` field from Other Video NFO metadata as XMLTV description
|
||||
- Add detailed warning log when a file is added to ErsatzTV more than once
|
||||
|
||||
### Fixed
|
||||
- Fix updating (re-adding) Trakt lists to properly use new metadata ids that were not present when originally added
|
||||
- Fix local show library scanning with non-english season folder names, e.g. `Staffel 02`
|
||||
- Fix bug where local libraries would merge with media server libraries when the same file was added to both libraries
|
||||
- Fix transcoding some 10-bit content from media servers using VAAPI acceleration
|
||||
- Fix decoding of MPEG-4 Part 2 (e.g. DivX) content using NVIDIA acceleration
|
||||
- Fix color normalization from `bt470bg` to `bt709` using QSV acceleration
|
||||
- Fix adding files to search index with unknown video codec
|
||||
- Fix subtitle burn-in (embedded or external) using Jellyfin, Emby and Plex libraries
|
||||
- **This requires a one-time full library scan, which may take a long time with large libraries.**
|
||||
|
||||
### Changed
|
||||
- Use Poster artwork for XMLTV if available
|
||||
- If Poster artwork is unavailable, use Thumbnail
|
||||
- Improve XMLTV response time by caching data as playouts are updated
|
||||
|
||||
## [0.7.6-beta] - 2023-03-24
|
||||
### Added
|
||||
- Add `Troubleshooting` page with aggregated settings/hardware accel info for easy reference
|
||||
- Read `director` fields from music video NFO metadata
|
||||
- Pass `directors` and `studios` to music video credit templates
|
||||
- Add optional JSON Web Token (JWT) query string auth for streaming endpoints (everything under `/iptv`)
|
||||
- This can be configured using the following env var (note the double underscore separator `__`)
|
||||
- `JWT__ISSUERSIGNINGKEY`
|
||||
- When configured, a JWT signed with the configured signing key is required to be passed in the query string as `access_token`, for example:
|
||||
- `http://localhost:8409/iptv/channels.m3u?access_token=ABCDEF`
|
||||
- `http://localhost:8409/iptv/xmltv.xml?access_token=ABCDEF`
|
||||
- When channels are retrieved this way, the access token will automatically be passed through to all necessary urls
|
||||
- Note that ONLY the `/iptv` endpoints will require auth when JWT is configured
|
||||
|
||||
### Fixed
|
||||
- Fix scaling anamorphic content from non-local libraries
|
||||
- Fix direct streaming content from Jellyfin that has external subtitles
|
||||
- Note that these subtitles are not currently supported in ETV, but they did cause a playback issue
|
||||
- Fix Jellyfin, Emby and Plex library scans that wouldn't work in certain timezones
|
||||
- Fix song normalization to match FFmpeg Profile bit depth
|
||||
- Fix bug playing some external subtitle files (e.g. with an apostrophe in the file name)
|
||||
- Fix bug detecting VAAPI capabilities when no device is selected in active FFmpeg Profile
|
||||
- Fix playout mode duration bugs in XMLTV
|
||||
- Tail mode filler will properly include filler duration in XMLTV
|
||||
- Duration that wraps across midnight will no longer have overlapping items in XMLTV
|
||||
- Maintain collection progress across all alternate schedules on a playout
|
||||
- Fix color normalization from `bt470bg` to `bt709`
|
||||
|
||||
### Changed
|
||||
- Ignore case of video and audio file extensions in local folder scanner
|
||||
- For example, the scanner will now find `movie.MKV` as well as `movie.mkv` on case-sensitive filesystems
|
||||
- Include multiple `display-name` entries in generated XMLTV
|
||||
- Plex should now display the channel number instead of the channel id (e.g. `1.2` instead of `1.2.etv`)
|
||||
- Rework concurrency a bit
|
||||
- Playout builds are no longer blocked by library scans
|
||||
- Adding Trakt lists is no longer blocked by library scans
|
||||
- All library scans (local and media servers) run sequentially
|
||||
- Emby collection scanning will no longer happen after every (automatic or forced) library scan
|
||||
- Automatic/periodic scans will check collections one time after all libraries have been scanned
|
||||
- There is a new table in the `Media` > `Libraries` page with a button to manually re-scan Emby collections as needed
|
||||
- For performance reasons, limit console log output to errors on Windows
|
||||
- Other platforms are unchanged
|
||||
- Log file behavior is unchanged
|
||||
|
||||
## [0.7.5-beta] - 2023-03-05
|
||||
### Added
|
||||
- Use AV1 hardware-accelerated decoder with VAAPI, QSV, NVIDIA when available
|
||||
- Use VP9 hardware-accelerated decoder with VAAPI when available
|
||||
|
||||
### Fixed
|
||||
- Align default docker image (no acceleration) with new images from [ErsatzTV-ffmpeg](https://github.com/jasongdove/ErsatzTV-ffmpeg)
|
||||
- Fix some transcoding pipelines that use software decoders
|
||||
- Improve VAAPI encoder capability detection on newer hardware
|
||||
- Fix trash page to properly display episodes with missing metadata or titles
|
||||
- Fix playback of content with yuv444p10le pixel format
|
||||
- Fix case where some multi-episode files from Plex would crash the scanner
|
||||
|
||||
### Changed
|
||||
- Upgrade all docker images and windows builds to ffmpeg 6.0
|
||||
- Plex, Jellyfin and Emby libraries now retrieve all metadata and statistics from the media server
|
||||
- File systems will no longer be periodically scanned for libraries using these media sources
|
||||
- Plex, Jellyfin and Emby libraries now direct stream content when files are not found on ErsatzTV's file system
|
||||
- Content will still be normalized according to the Channel and FFmpeg Profile settings
|
||||
- Streaming from disk is preferred, so every playback attempt will first check the local file system
|
||||
- Use libvpl instead of libmfx to provide intel acceleration in vaapi docker images
|
||||
- Search queries no longer remove duplicate results as this was causing incorrect behavior
|
||||
- Prioritize audio streams that are flagged as "default" over number of audio channels
|
||||
- For example, a video with a stereo commentary track and a mono "default" track will now prefer the "default" track
|
||||
- Support many more season folder names with local television libraries
|
||||
|
||||
## [0.7.4-beta] - 2023-02-12
|
||||
### Added
|
||||
- Add button to copy/clone schedule from schedules table
|
||||
- Synchronize episode tags and genres from Jellyfin, Emby and Local show libraries
|
||||
- Add `Deep Scan` button to Jellyfin and Emby libraries
|
||||
- This is now required to update some metadata for existing libraries, when targeted updates are not possible
|
||||
- For example, if you already have tags and genres on your episodes in Jellyfin or Emby, you will need to deep scan each library to update that metadata on existing items in ErsatzTV
|
||||
|
||||
### Fixed
|
||||
- Fix many QSV pipeline bugs
|
||||
- Fix MPEG2 video format with QSV and VAAPI acceleration
|
||||
- Fix playback of content with undefined colorspace
|
||||
- Fix NVIDIA color normalization with VP9 sources
|
||||
- Fix fallback filler looping
|
||||
- Fix bug where some libraries would never scan
|
||||
- Fix filler ordering so post-roll is properly scheduled after padded mid-roll
|
||||
- Fix pre/post-roll filler padding when used with mid-roll
|
||||
- This caused overlapping schedule items, fallback filler that was too long, etc.
|
||||
|
||||
### Changed
|
||||
- Merge generated `Other Video` folder tags with tags from sidecar NFO
|
||||
- Prioritize audio streams that are flagged as "default" when multiple candidate streams are available
|
||||
- For example, a video with a stereo commentary track and a stereo "default" track will now prefer the "default" track
|
||||
|
||||
## [0.7.3-beta] - 2023-01-25
|
||||
### Added
|
||||
- Attempt to release memory periodically
|
||||
- Add OpenID Connect (OIDC) support (e.g. Keycloak, Authelia, Auth0)
|
||||
- This only protects the management UI; all streaming endpoints will continue to allow anonymous access
|
||||
- This can be configured with the following env vars (note the double underscore separator `__`)
|
||||
- `OIDC__AUTHORITY`
|
||||
- `OIDC__CLIENTID`
|
||||
- `OIDC__CLIENTSECRET`
|
||||
- `OIDC__LOGOUTURI` (optional, needed for Auth0, use `https://{auth0-domain}/v2/logout?client_id={auth0-client-id}` with proper values for domain and client-id)
|
||||
- Add *experimental* alternate schedule system
|
||||
- This allows a single playout to dynamically select a schedule based on date criteria, for example:
|
||||
- Weekday vs weekend schedules
|
||||
- Summer vs fall schedules
|
||||
- Shark week schedules
|
||||
- Alternate schedules can be managed by clicking the calendar icon in the playout list
|
||||
- Playouts contain a prioritized (top to bottom) list of alternate schedules
|
||||
- Whenever a playout is built for a given day, ErsatzTV will check for a matching schedule from top to bottom
|
||||
- A given day must match all alternate schedule parameters; wildcards (`*any*`) will always match
|
||||
- Day of week
|
||||
- Day of month
|
||||
- Month
|
||||
- The lowest priority (bottom) item will always match all parameters, and can be considered a "default" or "fallback" schedule
|
||||
|
||||
### Fixed
|
||||
- Fix schedule editor crashing due to bad music video artist data
|
||||
- Fix bug where playouts would not maintain smart collection progress on schedules that use multiple smart collections
|
||||
- Fix library scanning on osx-arm64
|
||||
- Fix ability to remove some media server libraries from ErsatzTV
|
||||
|
||||
### Changed
|
||||
- Always use software pipeline for error display
|
||||
- This ensures errors will display even when hardware acceleration is misconfigured
|
||||
- Call scanner process only when scanning is required based on library refresh interval
|
||||
- Use lower process priority for scanner process with unforced (automatic) library scans
|
||||
- Disable V2 UI and APIs by default
|
||||
- V2 UI can be re-enabled by setting the env var `ETV_UI_V2` to any value
|
||||
|
||||
## [0.7.2-beta] - 2023-01-05
|
||||
### Fixed
|
||||
- Fix VAAPI encoding in docker by switching to non-free driver
|
||||
|
||||
### Changed
|
||||
- Rewrite log page to read directly from log files instead of sqlite
|
||||
|
||||
## [0.7.1-beta] - 2023-01-03
|
||||
### Added
|
||||
- Add new music video credit templates
|
||||
|
||||
### Fixed
|
||||
- Fix many transcoding failures caused by the colorspace filter
|
||||
- Fix song playback with VAAPI and NVENC
|
||||
- Fix edge case where some local movies would not automatically be restored from trash
|
||||
- Fix synchronizing Jellyfin and Emby collection items
|
||||
- Fix saving some external subtitle records to database
|
||||
|
||||
### Changed
|
||||
- Upgrade to dotnet 7
|
||||
- Upgrade all docker images to ubuntu jammy and ffmpeg 5.1.2
|
||||
- Limit library scan interval between 0 and 1,000,000
|
||||
- 0 means do not automatically scan libraries
|
||||
- 1 to 999,999 means scan if it has been that many hours since the last scan
|
||||
- Use new `ErsatzTV.Scanner` process for scanning all libraries
|
||||
- This should reduce the ongoing memory footprint
|
||||
|
||||
## [0.7.0-beta] - 2022-12-11
|
||||
### Fixed
|
||||
- Fix removing Jellyfin and Emby libraries that have been deleted from the source media server
|
||||
- Fix `Work-Ahead HLS Segmenter Limit` setting to properly limit number of channels that can work-ahead at once
|
||||
- Include base path value in generated channel playlist (M3U) and channel guide (XMLTV) links
|
||||
- Fix parsing song metadata from OGG audio files
|
||||
- Properly unlock/re-enable trakt list operations after an operation is canceled
|
||||
|
||||
### Added
|
||||
- Add (required) bit depth normalization option to ffmpeg profile
|
||||
- This can help if your card only supports e.g. h264 encoding, normalizing to 8 bits will allow the hardware encoder to be used
|
||||
- Extract font attachments after extracting text subtitles
|
||||
- This should improve SubStation Alpha subtitle rendering
|
||||
- Detect VAAPI capabilities and fallback to software decoding/encoding as needed
|
||||
- Add audio stream selector scripts for episodes and movies
|
||||
- This will let you customize which audio stream is selected for playback
|
||||
- Episodes are passed the following data:
|
||||
- `channelNumber`
|
||||
- `channelName`
|
||||
- `showTitle`
|
||||
- `showGuids`: array of string ids like `imdb_1234` or `tvdb_1234`
|
||||
- `seasonNumber`
|
||||
- `episodeNumber`
|
||||
- `episodeGuids`: array of string ids like `imdb_1234` or `tvdb_1234`
|
||||
- `preferredLanguageCodes`: array of string preferred language codes configured for the channel
|
||||
- `audioStreams`: array of audio stream data, each containing
|
||||
- `index`: the stream's index number, this is what the function needs to return
|
||||
- `channels`: the number of audio channels
|
||||
- `codec`: the audio codec
|
||||
- `isDefault`: bool indicating whether the stream is flagged as default
|
||||
- `isForced`: bool indicating whether the stream is flagged as forced
|
||||
- `language`: the stream's language
|
||||
- `title`: the stream's title
|
||||
- Movies are passed the following data:
|
||||
- `channelNumber`
|
||||
- `channelName`
|
||||
- `title`
|
||||
- `guids`: array of string ids like `imdb_1234` or `tvdb_1234`
|
||||
- `preferredLanguageCodes`: array of string preferred language codes configured for the channel
|
||||
- `audioStreams`: array of audio stream data, each containing
|
||||
- `index`: the stream's index number, this is what the function needs to return
|
||||
- `channels`: the number of audio channels
|
||||
- `codec`: the audio codec
|
||||
- `isDefault`: bool indicating whether the stream is flagged as default
|
||||
- `isForced`: bool indicating whether the stream is flagged as forced
|
||||
- `language`: the stream's language
|
||||
- `title`: the stream's title
|
||||
- Add new fields to search index
|
||||
- `video_codec`: the video codec
|
||||
- `video_bit_depth`: the number of bits in the video stream's pixel format, e.g. 8 or 10
|
||||
- `video_dynamic_range`: the video's dynamic range, either `sdr` or `hdr`
|
||||
|
||||
### Changed
|
||||
- Change `Multi-Episode Shuffle` scripting system to use Javascript instead of Lua
|
||||
|
||||
## [0.6.9-beta] - 2022-10-21
|
||||
### Fixed
|
||||
- Fix bug where tail or fallback filler would sometimes schedule much longer than expected
|
||||
- This only happened with fixed start schedule items following a schedule item with tail or fallback filler
|
||||
- Fix NFO reader bug that caused inaccurate warning messages about invalid XML and incomplete metadata
|
||||
- Fix reverse proxy SSL termination support by supporting `X-Forwarded-Proto` header
|
||||
- Fix automatic playout reset scheduling
|
||||
- Playouts would reset every 30 minutes between midnight and the configured time, instead of only at the configured time
|
||||
- XMLTV: properly group schedule items with `Custom Title` followed by item(s) with `Guide Mode` set to `Filler`
|
||||
|
||||
### Added
|
||||
- Add music video credits template system
|
||||
- Templates are selected in each channel's settings
|
||||
- Templates should be copied from `_default.ass.sbntxt` which is located in the config subfolder `templates/music-video-credits`
|
||||
- Copy the file, give it any name ending with `.ass.sbntext`, and only make edits to the copied file
|
||||
- The default template will be extracted and overwritten every time ErsatzTV is started
|
||||
- The template is an [Advanced SubStation Alpha](http://www.tcax.org/docs/ass-specs.htm) file using [scribian](https://github.com/scriban/scriban/tree/master/doc) template syntax
|
||||
- The following fields are available for use in the template:
|
||||
- `resolution`: the ffmpeg profile's resolution, which is used for margin calculations
|
||||
- `title`: the title of the music video
|
||||
- `track`: the music video's track number
|
||||
- `album`: the music video's album
|
||||
- `plot`: the music video's plot
|
||||
- `release_date`: the music video's release date
|
||||
- `artist`: the music videos artist (the parent folder)
|
||||
- `all_artists`: a list of additional artists from the music video's sidecar NFO metadata file
|
||||
- `duration`: the timespan duration of the music video, which can be used to calculate timing of additional subtitles
|
||||
- `stream_seek`: the timespan that ffmpeg will seek into the media item before beginning playback
|
||||
- Add `Multi-Episode Shuffle` playout order for `Television Show` schedule items
|
||||
- The purpose of this playout order is to improve randomization for shows that normally have intro, multiple episodes, and outro
|
||||
- This playout order requires splitting the parts into individual files (e.g. splitting `s01e01-03.mkv` into `s01e01.mkv`, `s01e02.mkv` and `s01e03.mkv`)
|
||||
- This playout order requires a lua script in the config subfolder `scripts/multi-episode-shuffle`
|
||||
- The lua script should be named for the television show's guid, e.g. `tvdb_12345.lua` or `imdb_tt123456789.lua`
|
||||
- The script defines the number of parts that each un-split file typically contains
|
||||
- The script also defines a function to map each episode to a part number (or no part number i.e. `nil` if an episode has not been split)
|
||||
- All groups of part numbers (i.e. all part 1s, all part 2s) will be shuffled
|
||||
- The playout order will then schedule a random part 1 followed by a random part 2, etc
|
||||
- Un-split (`nil`) episodes will be randomly placed between re-combined parts (e.g. part1, part2, part3, un-split, part1, part2, part3)
|
||||
- Add `ETV_BASE_URL` environment variable to support reverse proxies that use paths (e.g. `/ersatztv`)
|
||||
|
||||
### Changed
|
||||
- No longer place watermarks within content by default (e.g. within 4:3 content padded to a 16:9 resolution)
|
||||
- This can be re-enabled if desired using the `Place Within Source Content` checkbox in watermark settings
|
||||
|
||||
## [0.6.8-beta] - 2022-10-05
|
||||
### Fixed
|
||||
- Fix typo introduced in `0.6.7-beta` that stopped QSV HEVC encoder from working
|
||||
- Fix scaling logic for `Nvidia` acceleration and software mode
|
||||
- Attempt to position watermarks within content (not over added black padding)
|
||||
- Fix search results for `Other Videos` when NFO metadata is used
|
||||
- Properly synchronize tags from Emby movies and shows
|
||||
- Properly sync updated file paths from Plex
|
||||
- Fix numeric range search queries (e.g. `minutes:[5 TO 10]`, `minutes:[* TO 3]`)
|
||||
|
||||
### Added
|
||||
- Add `QSV Device` option to ffmpeg profile on linux
|
||||
- Add guids to search index (e.g. `imdb:tt000000`, `tvdb:12345`)
|
||||
|
||||
## [0.6.7-beta] - 2022-09-05
|
||||
### Fixed
|
||||
- When all audio streams are selected with `HLS Direct`, explicitly copy them without transcoding
|
||||
- This only happens when the channel does not have a `Preferred Audio Language`
|
||||
- Fix scanner crash caused by invalid mtime
|
||||
- `VAAPI`: Downgrade libva from 2.15 to 2.14
|
||||
- Fix bug with XMLTV that caused some filler to display with primary content details
|
||||
- Multiple fixes for content scaling with `Nvidia`, `Qsv` and `Vaapi` accelerations
|
||||
- Properly scale image-based subtitles
|
||||
- Fix bug where a schedule containing a single item (fixed start and flood) would never finish building a playout
|
||||
- Logic was also added to detect infinite playout build loops in the future and stop them
|
||||
- Fix bug where `Other Videos` wouldn't be included in scheduling mode `Shuffle In Order`
|
||||
|
||||
### Added
|
||||
- Add `Preferred Audio Title` feature
|
||||
- Preference can be configured in channel settings and overridden on schedule items
|
||||
- When a title is specified, audio streams that contain that title (case-insensitive search) will be prioritized
|
||||
- This can be helpful for creating channels that use commentary tracks
|
||||
- External tooling exists to easily update title/name metadata if your audio streams don't already have this metadata
|
||||
- Add `Amf` hardware acceleration option for AMD GPUs on Windows
|
||||
- Add `QSV Extra Hardware Frames` parameter for tuning QSV acceleration
|
||||
- Performance may improve on some systems after doubling or halving the default value of `64`
|
||||
|
||||
## [0.6.6-beta] - 2022-08-17
|
||||
### Fixed
|
||||
- Use MIME Type `application/x-mpegurl` for all playlists instead of `application/vnd.apple.mpegurl`
|
||||
- Replace `setsar` filter with `setdar` filter
|
||||
- `setsar` caused issues scaling between two different aspect ratios
|
||||
- For example, some 4:3 content would appear stretched when scaled to a 16:9 resolution
|
||||
- `setdar` is now only used when aspect ratios match
|
||||
- Prioritize aspect ratio from container when video stream contains conflicting aspect ratio
|
||||
- This is usually caused by bad authoring, but the change should improve scaling behavior for edge cases
|
||||
|
||||
### Added
|
||||
- Support DSD audio file formats (DFF and DSF) in local song libraries
|
||||
- Support OGG audio file formats (OGG, OPUS, OGA, OGX, SPX) in local song libraries
|
||||
|
||||
### Changed
|
||||
- Always return playlist after a maximum of 8 seconds while starting up an HLS Segmenter session
|
||||
- Use multi-variant playlists instead of redirects for HLS Segmenter sessions
|
||||
- Upgrade ffmpeg from 5.0 to 5.1 in most docker images (not ARM variants)
|
||||
- Upgrading from 5.0 to 5.1 is also recommended for other installations (Windows, Linux)
|
||||
|
||||
## [0.6.5-beta] - 2022-08-02
|
||||
### Fixed
|
||||
- Fix database initializer; fresh installs with v0.6.4-beta are missing some config data and should upgrade
|
||||
|
||||
## [0.6.4-beta] - 2022-07-28
|
||||
### Fixed
|
||||
- Fix subtitle stream selection when subtitle language is different than audio language
|
||||
- Fix bug with unsupported AAC channel layouts
|
||||
- Fix NVIDIA second-gen maxwell capabilities detection
|
||||
- Return distinct search results for episodes and other videos that have the same title
|
||||
- For example, two other videos both named `Trailer` would previously have displayed as one item in search results
|
||||
- Fix schedules that would begin to repeat the same content in the same order after a couple of days
|
||||
|
||||
### Added
|
||||
- Add `640x480` resolution
|
||||
|
||||
## [0.6.3-beta] - 2022-07-04
|
||||
### Fixed
|
||||
- Maintain stream continuity when playout is rebuilt for a channel that is actively being streamed
|
||||
- Properly apply changes to episode title, sort title, outline and plot from Plex
|
||||
- Fix search index for other videos and songs
|
||||
- In previous versions, some libraries would incorrectly display only one item
|
||||
- Properly display old versions of renamed items in trash
|
||||
|
||||
### Added
|
||||
- Add `Minimum Log Level` option to `Settings` page
|
||||
- Other methods of configuring the log level will no longer work
|
||||
|
||||
## [0.6.2-beta] - 2022-06-18
|
||||
### Fixed
|
||||
- Fix content repeating for up to a minute near the top of every hour
|
||||
@@ -1252,7 +1692,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.2-beta...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.8.0-beta...HEAD
|
||||
[0.8.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.9-beta...v0.8.0-beta
|
||||
[0.7.9-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.8-beta...v0.7.9-beta
|
||||
[0.7.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.7-beta...v0.7.8-beta
|
||||
[0.7.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.6-beta...v0.7.7-beta
|
||||
[0.7.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.5-beta...v0.7.6-beta
|
||||
[0.7.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.4-beta...v0.7.5-beta
|
||||
[0.7.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.3-beta...v0.7.4-beta
|
||||
[0.7.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.2-beta...v0.7.3-beta
|
||||
[0.7.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.1-beta...v0.7.2-beta
|
||||
[0.7.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.0-beta...v0.7.1-beta
|
||||
[0.7.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.9-beta...v0.7.0-beta
|
||||
[0.6.9-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.8-beta...v0.6.9-beta
|
||||
[0.6.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.7-beta...v0.6.8-beta
|
||||
[0.6.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.6-beta...v0.6.7-beta
|
||||
[0.6.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.5-beta...v0.6.6-beta
|
||||
[0.6.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.4-beta...v0.6.5-beta
|
||||
[0.6.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.3-beta...v0.6.4-beta
|
||||
[0.6.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.2-beta...v0.6.3-beta
|
||||
[0.6.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.1-beta...v0.6.2-beta
|
||||
[0.6.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.0-beta...v0.6.1-beta
|
||||
[0.6.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.8-beta...v0.6.0-beta
|
||||
@@ -1351,4 +1809,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
[0.0.5-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.4-prealpha...v0.0.5-prealpha
|
||||
[0.0.4-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.3-prealpha...v0.0.4-prealpha
|
||||
[0.0.3-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.1-prealpha...v0.0.3-prealpha
|
||||
[0.0.1-prealpha]: https://github.com/jasongdove/ErsatzTV/releases/tag/v0.0.1-prealpha
|
||||
[0.0.1-prealpha]: https://github.com/jasongdove/ErsatzTV/releases/tag/v0.0.1-prealpha
|
||||
|
||||
2
ErsatzTV-Windows/.gitignore
vendored
Normal file
2
ErsatzTV-Windows/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
|
||||
1028
ErsatzTV-Windows/Cargo.lock
generated
Normal file
1028
ErsatzTV-Windows/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
ErsatzTV-Windows/Cargo.toml
Normal file
19
ErsatzTV-Windows/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "ersatztv_windows"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tray-item = { git = "https://github.com/olback/tray-item-rs" }
|
||||
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
|
||||
process_path = "0.1.4"
|
||||
|
||||
[dependencies.windows]
|
||||
version = "0.43.0"
|
||||
features = [
|
||||
"Win32_System_Console",
|
||||
"Win32_Foundation"
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
windres = "*"
|
||||
@@ -1,33 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<RootNamespace>ErsatzTV_Windows</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationIcon>Ersatztv.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Ersatztv.ico">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Program.cs">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace ErsatzTV_Windows;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The main entry point for the application.
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
public static void Main()
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new TrayApplicationContext());
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Diagnostics;
|
||||
using CliWrap;
|
||||
|
||||
namespace ErsatzTV_Windows;
|
||||
|
||||
public class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
private readonly NotifyIcon _trayIcon;
|
||||
private readonly CancellationTokenSource _tokenSource;
|
||||
|
||||
public TrayApplicationContext()
|
||||
{
|
||||
_trayIcon = new NotifyIcon
|
||||
{
|
||||
Icon = new Icon("./Ersatztv.ico"),
|
||||
ContextMenuStrip = new ContextMenuStrip(),
|
||||
Visible = true
|
||||
};
|
||||
|
||||
_tokenSource = new CancellationTokenSource();
|
||||
|
||||
AddMenuItem("Launch Web UI", LaunchWebUI);
|
||||
AddMenuItem("Show Logs", ShowLogs);
|
||||
_trayIcon.ContextMenuStrip.Items.Add(new ToolStripSeparator());
|
||||
AddMenuItem("Exit", Exit);
|
||||
|
||||
string folder = AppContext.BaseDirectory;
|
||||
string exe = Path.Combine(folder, "ErsatzTV.exe");
|
||||
|
||||
if (File.Exists(exe))
|
||||
{
|
||||
|
||||
Cli.Wrap(exe)
|
||||
.WithWorkingDirectory(folder)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(_tokenSource.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddMenuItem(string name, EventHandler action)
|
||||
{
|
||||
var item = new ToolStripMenuItem(name);
|
||||
item.Click += action;
|
||||
_trayIcon.ContextMenuStrip.Items.Add(item);
|
||||
}
|
||||
|
||||
private void LaunchWebUI(object? sender, EventArgs e)
|
||||
{
|
||||
var process = new Process();
|
||||
process.StartInfo.UseShellExecute = true;
|
||||
process.StartInfo.FileName = "http://localhost:8409";
|
||||
process.Start();
|
||||
}
|
||||
|
||||
private void ShowLogs(object? sender, EventArgs e)
|
||||
{
|
||||
if (!Directory.Exists(FileSystemLayout.LogsFolder))
|
||||
{
|
||||
Directory.CreateDirectory(FileSystemLayout.LogsFolder);
|
||||
}
|
||||
|
||||
var process = new Process();
|
||||
process.StartInfo.UseShellExecute = true;
|
||||
process.StartInfo.FileName = FileSystemLayout.LogsFolder;
|
||||
process.Start();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
_tokenSource?.Cancel();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private void Exit(object? sender, EventArgs e)
|
||||
{
|
||||
// Hide tray icon, otherwise it will remain shown until user mouses over it
|
||||
_trayIcon.Visible = false;
|
||||
Application.Exit();
|
||||
}
|
||||
}
|
||||
5
ErsatzTV-Windows/build.rs
Normal file
5
ErsatzTV-Windows/build.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use windres::Build;
|
||||
|
||||
fn main() {
|
||||
Build::new().compile("ersatztv_windows.rc").unwrap();
|
||||
}
|
||||
2
ErsatzTV-Windows/ersatztv_windows.rc
Normal file
2
ErsatzTV-Windows/ersatztv_windows.rc
Normal file
@@ -0,0 +1,2 @@
|
||||
id ICON "ersatztv.ico"
|
||||
ersatztv-icon ICON "ersatztv.ico"
|
||||
112
ErsatzTV-Windows/src/main.rs
Normal file
112
ErsatzTV-Windows/src/main.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use special_folder::SpecialFolder;
|
||||
use std::fs;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Child;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use windows::Win32::System::Console;
|
||||
use {std::sync::mpsc, tray_item::TrayItem};
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
enum Message {
|
||||
Exit,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut tray = TrayItem::new("ErsatzTV", "ersatztv-icon").unwrap();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
tray.add_menu_item("Launch Web UI", || {
|
||||
let _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("/C")
|
||||
.arg("start")
|
||||
.arg("http://localhost:8409")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.add_menu_item("Show Logs", || {
|
||||
let path = SpecialFolder::LocalApplicationData
|
||||
.get()
|
||||
.unwrap()
|
||||
.join("ersatztv")
|
||||
.join("logs");
|
||||
match path.to_str() {
|
||||
None => {}
|
||||
Some(folder) => {
|
||||
fs::create_dir_all(folder).unwrap();
|
||||
let _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("/C")
|
||||
.arg("start")
|
||||
.arg(folder)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.inner_mut().add_separator().unwrap();
|
||||
|
||||
tray.add_menu_item("Exit", move || {
|
||||
tx.send(Message::Exit).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path = process_path::get_executable_path();
|
||||
let mut child: Option<Child> = None;
|
||||
match path {
|
||||
None => {}
|
||||
Some(path) => {
|
||||
let etv = path.parent().unwrap().join("ErsatzTV.exe");
|
||||
if etv.exists() {
|
||||
match etv.to_str() {
|
||||
None => {}
|
||||
Some(etv) => {
|
||||
child = Some(
|
||||
Command::new(etv)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(Message::Exit) => {
|
||||
match child {
|
||||
None => {}
|
||||
Some(mut child) => {
|
||||
unsafe {
|
||||
if Console::AttachConsole(child.id()) == true
|
||||
{
|
||||
Console::GenerateConsoleCtrlEvent(Console::CTRL_C_EVENT, 0);
|
||||
}
|
||||
}
|
||||
child.wait().unwrap();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,41 @@
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaItems.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
|
||||
public GetAllArtistsHandler(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||
|
||||
public Task<List<NamedMediaItemViewModel>> Handle(
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
GetAllArtists request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_artistRepository.GetAllArtists()
|
||||
.Map(
|
||||
list => list.Filter(
|
||||
a => !string.IsNullOrWhiteSpace(
|
||||
a.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => string.Empty))))
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Artist> allArtists = await dbContext.Artists
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArtistMetadata)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return allArtists.Bind(a => ProjectArtist(a)).ToList();
|
||||
}
|
||||
|
||||
private static Option<NamedMediaItemViewModel> ProjectArtist(Artist a)
|
||||
{
|
||||
foreach (ArtistMetadata metadata in a.ArtistMetadata.HeadOrNone())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||
{
|
||||
return ProjectToViewModel(a);
|
||||
}
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ public record ChannelViewModel(
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
int PlayoutCount,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode);
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate);
|
||||
|
||||
@@ -12,9 +12,11 @@ public record CreateChannel
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
|
||||
@@ -71,9 +71,11 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
||||
StreamingMode = request.StreamingMode,
|
||||
Artwork = artwork,
|
||||
PreferredAudioLanguageCode = preferredAudioLanguageCode,
|
||||
PreferredAudioTitle = request.PreferredAudioTitle,
|
||||
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
|
||||
SubtitleMode = request.SubtitleMode,
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate
|
||||
};
|
||||
|
||||
foreach (int id in watermarkId)
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Task>>;
|
||||
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -1,23 +1,61 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Task>>
|
||||
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public DeleteChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
public DeleteChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Task>> Handle(DeleteChannel request, CancellationToken cancellationToken) =>
|
||||
(await ChannelMustExist(request))
|
||||
.Map(DoDeletion)
|
||||
.ToEither<Task>();
|
||||
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);
|
||||
|
||||
private Task DoDeletion(int channelId) => _channelRepository.Delete(channelId);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, int>> ChannelMustExist(DeleteChannel deleteChannel) =>
|
||||
(await _channelRepository.Get(deleteChannel.ChannelId))
|
||||
.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.")
|
||||
.Map(c => c.Id);
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.Channels.Remove(channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_localFileSystem.FileExists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
// refresh channel list to remove channel that has no playout
|
||||
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteChannel deleteChannel)
|
||||
{
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId);
|
||||
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record RefreshChannelData(string ChannelNumber) : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,521 @@
|
||||
using System.Xml;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RefreshChannelDataHandler> _logger;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public RefreshChannelDataHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RefreshChannelDataHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
|
||||
{
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<PlayoutItem> sorted = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(em => em.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList());
|
||||
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
// skip all filler that isn't pre-roll
|
||||
var i = 0;
|
||||
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||
sorted[i].FillerKind != FillerKind.PreRoll)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < sorted.Count)
|
||||
{
|
||||
PlayoutItem startItem = sorted[i];
|
||||
int j = i;
|
||||
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
PlayoutItem displayItem = sorted[j];
|
||||
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
||||
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.Tail or FillerKind.Fallback))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
int customShowId = -1;
|
||||
if (displayItem.MediaItem is Episode ep)
|
||||
{
|
||||
customShowId = ep.Season.ShowId;
|
||||
}
|
||||
|
||||
bool isSameCustomShow = hasCustomTitle;
|
||||
for (int x = j; x <= finishIndex; x++)
|
||||
{
|
||||
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
|
||||
customShowId == e.Season.ShowId;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
|
||||
string stop = displayItem.GuideFinishOffset.HasValue
|
||||
? displayItem.GuideFinishOffset.Value.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty)
|
||||
: finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
|
||||
|
||||
string title = GetTitle(displayItem);
|
||||
string subtitle = GetSubtitle(displayItem);
|
||||
string description = GetDescription(displayItem);
|
||||
Option<ContentRating> contentRating = GetContentRating(displayItem);
|
||||
|
||||
await xml.WriteStartElementAsync(null, "programme", null);
|
||||
await xml.WriteAttributeStringAsync(null, "start", null, start);
|
||||
await xml.WriteAttributeStringAsync(null, "stop", null, stop);
|
||||
await xml.WriteAttributeStringAsync(null, "channel", null, $"{request.ChannelNumber}.etv");
|
||||
|
||||
await xml.WriteStartElementAsync(null, "title", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(title);
|
||||
await xml.WriteEndElementAsync(); // title
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "sub-title", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(subtitle);
|
||||
await xml.WriteEndElementAsync(); // subtitle
|
||||
}
|
||||
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "desc", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(description);
|
||||
await xml.WriteEndElementAsync(); // desc
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCustomTitle && displayItem.MediaItem is Movie movie)
|
||||
{
|
||||
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
|
||||
{
|
||||
if (metadata.Year.HasValue)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "date", null);
|
||||
await xml.WriteStringAsync(metadata.Year.Value.ToString());
|
||||
await xml.WriteEndElementAsync(); // date
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Movie");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
string poster = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(poster))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, poster);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCustomTitle && displayItem.MediaItem is MusicVideo musicVideo)
|
||||
{
|
||||
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata.HeadOrNone())
|
||||
{
|
||||
if (metadata.Year.HasValue)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "date", null);
|
||||
await xml.WriteStringAsync(metadata.Year.Value.ToString());
|
||||
await xml.WriteEndElementAsync(); // date
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Music");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
// music video genres
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
// artist genres
|
||||
Option<ArtistMetadata> maybeMetadata =
|
||||
Optional(musicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten();
|
||||
foreach (ArtistMetadata artistMetadata in maybeMetadata)
|
||||
{
|
||||
foreach (Genre genre in Optional(artistMetadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
}
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCustomTitle && displayItem.MediaItem is Song song)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Music");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
foreach (SongMetadata metadata in song.SongMetadata.HeadOrNone())
|
||||
{
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
|
||||
{
|
||||
Option<ShowMetadata> maybeMetadata =
|
||||
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
|
||||
foreach (ShowMetadata metadata in maybeMetadata)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Series");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
int s = await Optional(episode.Season?.SeasonNumber).IfNoneAsync(-1);
|
||||
// TODO: multi-episode?
|
||||
int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1);
|
||||
if (s >= 0 && e > 0)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "episode-num", null);
|
||||
await xml.WriteAttributeStringAsync(null, "system", null, "onscreen");
|
||||
await xml.WriteStringAsync($"S{s:00}E{e:00}");
|
||||
await xml.WriteEndElementAsync(); // episode-num
|
||||
|
||||
await xml.WriteStartElementAsync(null, "episode-num", null);
|
||||
await xml.WriteAttributeStringAsync(null, "system", null, "xmltv_ns");
|
||||
await xml.WriteStringAsync($"{s - 1}.{e - 1}.0/1");
|
||||
await xml.WriteEndElementAsync(); // episode-num
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "previously-shown", null);
|
||||
await xml.WriteEndElementAsync(); // previously-shown
|
||||
|
||||
foreach (ContentRating rating in contentRating)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "rating", null);
|
||||
foreach (string system in rating.System)
|
||||
{
|
||||
await xml.WriteAttributeStringAsync(null, "system", null, system);
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "value", null);
|
||||
await xml.WriteStringAsync(rating.Value);
|
||||
await xml.WriteEndElementAsync(); // value
|
||||
await xml.WriteEndElementAsync(); // rating
|
||||
}
|
||||
|
||||
await xml.WriteEndElementAsync(); // programme
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
|
||||
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
|
||||
{
|
||||
string artworkPath = artwork.Path;
|
||||
|
||||
int height = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Thumbnail => 220,
|
||||
_ => 440
|
||||
};
|
||||
|
||||
if (artworkPath.StartsWith("jellyfin://"))
|
||||
{
|
||||
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
||||
}
|
||||
else if (artworkPath.StartsWith("emby://"))
|
||||
{
|
||||
artworkPath = EmbyUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
||||
}
|
||||
else
|
||||
{
|
||||
string artworkFolder = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Thumbnail => "thumbnails",
|
||||
_ => "posters"
|
||||
};
|
||||
|
||||
artworkPath = $"{{RequestBase}}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg{{AccessTokenUri}}";
|
||||
}
|
||||
|
||||
return artworkPath;
|
||||
}
|
||||
|
||||
private static string GetTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return playoutItem.CustomTitle;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||
.IfNone("[unknown movie]"),
|
||||
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown show]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Artist ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSubtitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDescription(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => mvm.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private Option<ContentRating> GetContentRating(PlayoutItem playoutItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata
|
||||
.HeadOrNone()
|
||||
.Match(mm => ParseContentRating(mm.ContentRating, "MPAA"), () => None),
|
||||
Episode e => e.Season.Show.ShowMetadata
|
||||
.HeadOrNone()
|
||||
.Match(sm => ParseContentRating(sm.ContentRating, "VCHIP"), () => None),
|
||||
_ => None
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
|
||||
{
|
||||
Option<string> maybeFirst = (contentRating ?? string.Empty).Split('/').HeadOrNone();
|
||||
return maybeFirst.Map(
|
||||
first =>
|
||||
{
|
||||
string[] split = first.Split(':');
|
||||
if (split.Length == 2)
|
||||
{
|
||||
return split[0].ToLowerInvariant() == "us"
|
||||
? new ContentRating(system, split[1].ToUpperInvariant())
|
||||
: new ContentRating(None, split[1].ToUpperInvariant());
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(first)
|
||||
? Option<ContentRating>.None
|
||||
: new ContentRating(None, first);
|
||||
}).Flatten();
|
||||
}
|
||||
|
||||
private string GetPrioritizedArtworkPath(Metadata metadata)
|
||||
{
|
||||
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Map(a => GetArtworkUrl(a, ArtworkKind.Poster));
|
||||
|
||||
if (maybeArtwork.IsNone)
|
||||
{
|
||||
maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Thumbnail)
|
||||
.HeadOrNone()
|
||||
.Map(a => GetArtworkUrl(a, ArtworkKind.Thumbnail));
|
||||
}
|
||||
|
||||
return maybeArtwork.IfNone(string.Empty);
|
||||
}
|
||||
|
||||
private record ContentRating(Option<string> System, string Value);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record RefreshChannelList : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Xml;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public RefreshChannelListHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken)
|
||||
{
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "channel", null);
|
||||
await xml.WriteAttributeStringAsync(null, "id", null, $"{channel.Number}.etv");
|
||||
|
||||
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||
await xml.WriteStringAsync($"{channel.Number} {channel.Name}");
|
||||
await xml.WriteEndElementAsync(); // display-name (number and name)
|
||||
|
||||
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||
await xml.WriteStringAsync(channel.Number);
|
||||
await xml.WriteEndElementAsync(); // display-name (number)
|
||||
|
||||
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||
await xml.WriteStringAsync(channel.Name);
|
||||
await xml.WriteEndElementAsync(); // display-name (name)
|
||||
|
||||
foreach (string category in GetCategories(channel.Categories))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(category);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, GetIconUrl(channel));
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
|
||||
await xml.WriteEndElementAsync(); // channel
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChannelResult> GetChannels(TvContext dbContext)
|
||||
{
|
||||
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)
|
||||
order by CAST(C.Number as real)";
|
||||
|
||||
await using var reader = (DbDataReader)await dbContext.Connection.ExecuteReaderAsync(QUERY);
|
||||
Func<DbDataReader, ChannelResult> rowParser = reader.GetRowParser<ChannelResult>();
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
yield return rowParser(reader);
|
||||
}
|
||||
|
||||
while (await reader.NextResultAsync())
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetCategories(string categories) =>
|
||||
(categories ?? string.Empty).Split(',')
|
||||
.Map(s => s.Trim())
|
||||
.Filter(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
private static string GetIconUrl(ChannelResult channel) =>
|
||||
string.IsNullOrWhiteSpace(channel.ArtworkPath)
|
||||
? "{RequestBase}/iptv/images/ersatztv-500.png{AccessTokenUri}"
|
||||
: $"{{RequestBase}}/iptv/logos/{channel.ArtworkPath}.jpg{{AccessTokenUri}}";
|
||||
|
||||
// ReSharper disable once ClassNeverInstantiated.Local
|
||||
private record ChannelResult(string Number, string Name, string Categories, string ArtworkPath);
|
||||
}
|
||||
@@ -13,9 +13,11 @@ public record UpdateChannel
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -15,13 +15,13 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateChannelHandler(
|
||||
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_ffmpegWorkerChannel = ffmpegWorkerChannel;
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
@@ -42,9 +42,11 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
||||
c.Categories = update.Categories;
|
||||
c.FFmpegProfileId = update.FFmpegProfileId;
|
||||
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
|
||||
c.PreferredAudioTitle = update.PreferredAudioTitle;
|
||||
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
|
||||
c.SubtitleMode = update.SubtitleMode;
|
||||
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
|
||||
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
|
||||
c.Artwork ??= new List<Artwork>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo))
|
||||
@@ -83,7 +85,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,15 @@ internal static class Mapper
|
||||
channel.FFmpegProfileId,
|
||||
GetLogo(channel),
|
||||
channel.PreferredAudioLanguageCode,
|
||||
channel.PreferredAudioTitle,
|
||||
channel.StreamingMode,
|
||||
channel.WatermarkId,
|
||||
channel.FallbackFillerId,
|
||||
channel.Playouts?.Count ?? 0,
|
||||
channel.PreferredSubtitleLanguageCode,
|
||||
channel.SubtitleMode,
|
||||
channel.MusicVideoCreditsMode);
|
||||
channel.MusicVideoCreditsMode,
|
||||
channel.MusicVideoCreditsTemplate);
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
|
||||
@@ -86,10 +86,20 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
if (distinct.Any())
|
||||
{
|
||||
_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);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelGuide(string Scheme, string Host) : IRequest<ChannelGuide>;
|
||||
public record GetChannelGuide
|
||||
(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<Either<BaseError, ChannelGuide>>;
|
||||
|
||||
@@ -1,23 +1,72 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public GetChannelGuideHandler(
|
||||
IChannelRepository channelRepository,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_channelRepository = channelRepository;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAllForGuide()
|
||||
.Map(channels => new ChannelGuide(_recyclableMemoryStreamManager, request.Scheme, request.Host, channels));
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
GetChannelGuide request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!_localFileSystem.FileExists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
string accessTokenUri = string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
{
|
||||
accessTokenUri = $"?access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
|
||||
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
{
|
||||
if (fileName.Contains("channels"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
}
|
||||
|
||||
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelNameByPlayoutId(int PlayoutId) : IRequest<Option<string>>;
|
||||
@@ -0,0 +1,22 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
|
||||
{
|
||||
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)
|
||||
.MapT(p => p.Channel.Name);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelPlaylist(string Scheme, string Host, string Mode) : IRequest<ChannelPlaylist>;
|
||||
public record GetChannelPlaylist
|
||||
(string Scheme, string Host, string BaseUrl, string Mode, string AccessToken) : IRequest<ChannelPlaylist>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
@@ -14,7 +14,13 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels));
|
||||
.Map(
|
||||
channels => new ChannelPlaylist(
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels,
|
||||
request.AccessToken));
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
{
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest<Unit>;
|
||||
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest;
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey, Unit>
|
||||
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<Unit> Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
|
||||
{
|
||||
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken) =>
|
||||
await _configElementRepository.Upsert(request.Key, request.Value);
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateGeneralSettings(GeneralSettingsViewModel GeneralSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Serilog.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly LoggingLevelSwitch _loggingLevelSwitch;
|
||||
|
||||
public UpdateGeneralSettingsHandler(
|
||||
LoggingLevelSwitch loggingLevelSwitch,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_loggingLevelSwitch = loggingLevelSwitch;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateGeneralSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.GeneralSettings);
|
||||
|
||||
private async Task<Unit> ApplyUpdate(GeneralSettingsViewModel generalSettings)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.MinimumLogLevel);
|
||||
_loggingLevelSwitch.MinimumLevel = generalSettings.MinimumLogLevel;
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@ public class UpdateLibraryRefreshIntervalHandler :
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
|
||||
Optional(request.LibraryRefreshInterval)
|
||||
.Where(lri => lri > 0)
|
||||
.Where(lri => lri is >= 0 and < 1_000_000)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Tuner count must be greater than zero")
|
||||
.ToValidation<BaseError>("Library refresh interval must be zero or greated")
|
||||
.AsTask();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GeneralSettingsViewModel
|
||||
{
|
||||
public LogEventLevel MinimumLogLevel { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetGeneralSettings : IRequest<GeneralSettingsViewModel>;
|
||||
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, GeneralSettingsViewModel>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetGeneralSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<GeneralSettingsViewModel> Handle(GetGeneralSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LogEventLevel> maybeLogLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
|
||||
|
||||
return new GeneralSettingsViewModel
|
||||
{
|
||||
MinimumLogLevel = await maybeLogLevel.IfNoneAsync(LogEventLevel.Information)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
|
||||
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
public CallEmbyCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeEmbyCollections request)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyMediaSources
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
SynchronizeEmbyCollections request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-collections", request.EmbyMediaSourceId.ToString()
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeEmbyLibraryById>,
|
||||
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallEmbyLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle(
|
||||
ForceSynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>.
|
||||
Handle(
|
||||
SynchronizeEmbyLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby", request.EmbyLibraryId.ToString()
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeEmbyLibraryById request)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
ISynchronizeEmbyLibraryById request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IEmbyBackgroundServiceRequest;
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class SynchronizeEmbyCollectionsHandler : IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IEmbySecretStore _embySecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IEmbyCollectionScanner _scanner;
|
||||
|
||||
public SynchronizeEmbyCollectionsHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IEmbySecretStore embySecretStore,
|
||||
IEmbyCollectionScanner scanner)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_embySecretStore = embySecretStore;
|
||||
_scanner = scanner;
|
||||
}
|
||||
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, ConnectionParameters> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
SynchronizeCollections,
|
||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeEmbyCollections request) =>
|
||||
MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> MediaSourceMustExist(
|
||||
SynchronizeEmbyCollections request) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
|
||||
.Map(o => o.ToValidation<BaseError>("Emby media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
EmbyMediaSource embyMediaSource)
|
||||
{
|
||||
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(connection))
|
||||
.ToValidation<BaseError>("Emby media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
.Where(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.ToValidation<BaseError>("Emby media source requires an api key");
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> SynchronizeCollections(ConnectionParameters connectionParameters) =>
|
||||
await _scanner.ScanCollections(
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
connectionParameters.ApiKey);
|
||||
|
||||
private record ConnectionParameters(EmbyConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyLibraries(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IEmbyBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>,
|
||||
IEmbyBackgroundServiceRequest
|
||||
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest
|
||||
{
|
||||
int EmbyLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
bool DeepScan { get; }
|
||||
}
|
||||
|
||||
public record SynchronizeEmbyLibraryByIdIfNeeded(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
|
||||
{
|
||||
public bool ForceScan => false;
|
||||
public bool DeepScan => false;
|
||||
}
|
||||
|
||||
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
|
||||
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId, bool DeepScan) : ISynchronizeEmbyLibraryById
|
||||
{
|
||||
public bool ForceScan => true;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ namespace ErsatzTV.Application.Emby;
|
||||
public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmbyMediaSources,
|
||||
Either<BaseError, List<EmbyMediaSource>>>
|
||||
{
|
||||
private readonly ChannelWriter<IEmbyBackgroundServiceRequest> _channel;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public SynchronizeEmbyMediaSourcesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ChannelWriter<IEmbyBackgroundServiceRequest> channel)
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_channel = channel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<EmbyMediaSource>>> Handle(
|
||||
@@ -27,7 +27,7 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
|
||||
foreach (EmbyMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
// await _channel.WriteAsync(new SynchronizeEmbyAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _channel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||
}
|
||||
|
||||
return mediaSources;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record EmbyConnectionParametersViewModel(string Address);
|
||||
public record EmbyConnectionParametersViewModel(string Address, string ApiKey);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
@@ -8,15 +10,18 @@ namespace ErsatzTV.Application.Emby;
|
||||
public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnectionParameters,
|
||||
Either<BaseError, EmbyConnectionParametersViewModel>>
|
||||
{
|
||||
private readonly IEmbySecretStore _embySecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public GetEmbyConnectionParametersHandler(
|
||||
IMemoryCache memoryCache,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IEmbySecretStore embySecretStore)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_embySecretStore = embySecretStore;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle(
|
||||
@@ -30,7 +35,7 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
|
||||
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
|
||||
await Validate()
|
||||
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address))
|
||||
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address, cp.ApiKey))
|
||||
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
|
||||
|
||||
return maybeParameters.Match(
|
||||
@@ -44,7 +49,8 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
||||
EmbyMediaSourceMustExist()
|
||||
.BindT(MediaSourceMustHaveActiveConnection);
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
|
||||
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
|
||||
@@ -60,7 +66,20 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
.ToValidation<BaseError>("Emby media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
.Where(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.ToValidation<BaseError>("Emby media source requires an api key");
|
||||
}
|
||||
|
||||
private record ConnectionParameters(
|
||||
EmbyMediaSource EmbyMediaSource,
|
||||
EmbyConnection ActiveConnection);
|
||||
EmbyConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.3" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.2.32">
|
||||
<PackageReference Include="MediatR" Version="12.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.6.40">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="2.0.0" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -40,5 +40,6 @@
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=subtitles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=television_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=templates_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Cqueries/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
@@ -10,8 +10,10 @@ public record CreateFFmpegProfile(
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
|
||||
@@ -44,8 +44,10 @@ public class CreateFFmpegProfileHandler :
|
||||
HardwareAcceleration = request.HardwareAcceleration,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
VideoFormat = request.VideoFormat,
|
||||
BitDepth = request.BitDepth,
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
AudioFormat = request.AudioFormat,
|
||||
|
||||
@@ -19,7 +19,7 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, p => DoDeletion(dbContext, p));
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
}
|
||||
|
||||
private static async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
|
||||
|
||||
@@ -15,7 +15,7 @@ public class NewFFmpegProfileHandler : IRequestHandler<NewFFmpegProfile, FFmpegP
|
||||
|
||||
public async Task<FFmpegProfileViewModel> Handle(NewFFmpegProfile request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
int defaultResolutionId = await dbContext.ConfigElements
|
||||
.GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId)
|
||||
|
||||
@@ -11,8 +11,10 @@ public record UpdateFFmpegProfile(
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
|
||||
@@ -33,8 +33,15 @@ public class
|
||||
p.HardwareAcceleration = update.HardwareAcceleration;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
p.BitDepth = update.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: update.BitDepth;
|
||||
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
|
||||
@@ -52,7 +52,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
var test = new Process
|
||||
using var test = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
@@ -75,6 +75,9 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegSaveReports,
|
||||
request.Settings.SaveReports.ToString());
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegHlsDirectOutputFormat,
|
||||
request.Settings.HlsDirectOutputFormat);
|
||||
|
||||
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
|
||||
{
|
||||
|
||||
@@ -11,8 +11,10 @@ public record FFmpegProfileViewModel(
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
ResolutionViewModel Resolution,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -12,4 +14,5 @@ public class FFmpegSettingsViewModel
|
||||
public int HlsSegmenterIdleTimeout { get; set; }
|
||||
public int WorkAheadSegmenterLimit { get; set; }
|
||||
public int InitialSegmentCount { get; set; }
|
||||
public OutputFormatKind HlsDirectOutputFormat { get; set; }
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ internal static class Mapper
|
||||
profile.HardwareAcceleration,
|
||||
profile.VaapiDriver,
|
||||
profile.VaapiDevice,
|
||||
profile.QsvExtraHardwareFrames,
|
||||
Project(profile.Resolution),
|
||||
profile.VideoFormat,
|
||||
profile.BitDepth,
|
||||
profile.VideoBitrate,
|
||||
profile.VideoBufferSize,
|
||||
profile.AudioFormat,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
@@ -32,6 +33,8 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
|
||||
Option<int> initialSegmentCount =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount);
|
||||
Option<OutputFormatKind> outputFormatKind =
|
||||
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat);
|
||||
|
||||
var result = new FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -42,7 +45,8 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
|
||||
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
|
||||
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
|
||||
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
|
||||
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1)
|
||||
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1),
|
||||
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs)
|
||||
};
|
||||
|
||||
foreach (int watermarkId in watermark)
|
||||
|
||||
@@ -19,7 +19,7 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, ps => DoDeletion(dbContext, ps));
|
||||
return await validation.Apply(ps => DoDeletion(dbContext, ps));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, FillerPreset fillerPreset)
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GetAllFillerPresetsHandler : IRequestHandler<GetAllFillerPresets, L
|
||||
GetAllFillerPresets request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.FillerPresets.ToListAsync(cancellationToken)
|
||||
.Map(presets => presets.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
5
ErsatzTV.Application/IScannerBackgroundServiceRequest.cs
Normal file
5
ErsatzTV.Application/IScannerBackgroundServiceRequest.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public interface IScannerBackgroundServiceRequest
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public interface ISearchIndexBackgroundServiceRequest
|
||||
{
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public interface ISubtitleWorkerRequest
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeJellyfinLibraryById>,
|
||||
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallJellyfinLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>.
|
||||
Handle(
|
||||
ForceSynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>.
|
||||
Handle(
|
||||
SynchronizeJellyfinLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-jellyfin", request.JellyfinLibraryId.ToString()
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeJellyfinLibraryById request)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.JellyfinLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
ISynchronizeJellyfinLibraryById request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinAdminUserId(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinLibraries(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -3,18 +3,21 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public interface ISynchronizeJellyfinLibraryById : IRequest<Either<BaseError, string>>,
|
||||
IJellyfinBackgroundServiceRequest
|
||||
IScannerBackgroundServiceRequest
|
||||
{
|
||||
int JellyfinLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
bool DeepScan { get; }
|
||||
}
|
||||
|
||||
public record SynchronizeJellyfinLibraryByIdIfNeeded(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
|
||||
{
|
||||
public bool ForceScan => false;
|
||||
public bool DeepScan => false;
|
||||
}
|
||||
|
||||
public record ForceSynchronizeJellyfinLibraryById(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
|
||||
public record ForceSynchronizeJellyfinLibraryById
|
||||
(int JellyfinLibraryId, bool DeepScan) : ISynchronizeJellyfinLibraryById
|
||||
{
|
||||
public bool ForceScan => true;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ namespace ErsatzTV.Application.Jellyfin;
|
||||
public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<SynchronizeJellyfinMediaSources,
|
||||
Either<BaseError, List<JellyfinMediaSource>>>
|
||||
{
|
||||
private readonly ChannelWriter<IJellyfinBackgroundServiceRequest> _channel;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public SynchronizeJellyfinMediaSourcesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_channel = channel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinMediaSource>>> Handle(
|
||||
@@ -26,8 +26,10 @@ public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<Synchroniz
|
||||
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
|
||||
foreach (JellyfinMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinAdminUserId(mediaSource.Id),
|
||||
cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
|
||||
}
|
||||
|
||||
return mediaSources;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Channels;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.MediaSources;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Compact.Reader;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public abstract class CallLibraryScannerHandler<TRequest>
|
||||
{
|
||||
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private string _libraryName;
|
||||
|
||||
protected CallLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_configElementRepository = configElementRepository;
|
||||
_channel = channel;
|
||||
_mediator = mediator;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
protected async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
List<string> arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var forcefulCts = new CancellationTokenSource();
|
||||
|
||||
await using CancellationTokenRegistration link = cancellationToken.Register(
|
||||
() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10))
|
||||
);
|
||||
|
||||
CommandResult process = await Cli.Wrap(scanner)
|
||||
.WithArguments(arguments)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithStandardErrorPipe(PipeTarget.ToDelegate(ProcessLogOutput))
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(ProcessProgressOutput))
|
||||
.ExecuteAsync(forcefulCts.Token, cancellationToken);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return _libraryName ?? string.Empty;
|
||||
}
|
||||
|
||||
private static void ProcessLogOutput(string s)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
try
|
||||
{
|
||||
// make a new log event to force using local time
|
||||
// because the compact json writer used by the scanner
|
||||
// writes in UTC
|
||||
LogEvent logEvent = LogEventReader.ReadFromString(s);
|
||||
Log.Write(
|
||||
new LogEvent(
|
||||
logEvent.Timestamp.ToLocalTime(),
|
||||
logEvent.Level,
|
||||
logEvent.Exception,
|
||||
logEvent.MessageTemplate,
|
||||
logEvent.Properties.Map(pair => new LogEventProperty(pair.Key, pair.Value))));
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessProgressOutput(string s)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
try
|
||||
{
|
||||
ScannerProgressUpdate progressUpdate = JsonConvert.DeserializeObject<ScannerProgressUpdate>(s);
|
||||
if (progressUpdate != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(progressUpdate.LibraryName))
|
||||
{
|
||||
_libraryName = progressUpdate.LibraryName;
|
||||
}
|
||||
|
||||
if (progressUpdate.PercentComplete is not null)
|
||||
{
|
||||
var progress = new LibraryScanProgress(
|
||||
progressUpdate.LibraryId,
|
||||
progressUpdate.PercentComplete.Value);
|
||||
|
||||
await _mediator.Publish(progress);
|
||||
}
|
||||
|
||||
if (progressUpdate.ItemsToReindex.Length > 0)
|
||||
{
|
||||
var reindex = new ReindexMediaItems(progressUpdate.ItemsToReindex);
|
||||
await _channel.WriteAsync(reindex);
|
||||
}
|
||||
|
||||
if (progressUpdate.ItemsToRemove.Length > 0)
|
||||
{
|
||||
var remove = new RemoveMediaItems(progressUpdate.ItemsToRemove);
|
||||
await _channel.WriteAsync(remove);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "Unable to process scanner progress update");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract Task<DateTimeOffset> GetLastScan(TvContext dbContext, TRequest request);
|
||||
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
|
||||
|
||||
protected async Task<Validation<BaseError, string>> Validate(TRequest request)
|
||||
{
|
||||
int libraryRefreshInterval = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.IfNoneAsync(0);
|
||||
|
||||
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
DateTimeOffset lastScan = await GetLastScan(dbContext, request);
|
||||
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
|
||||
{
|
||||
return new ScanIsNotRequired();
|
||||
}
|
||||
|
||||
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
|
||||
? "ErsatzTV.Scanner.exe"
|
||||
: "ErsatzTV.Scanner";
|
||||
|
||||
string processFileName = Environment.ProcessPath ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(processFileName))
|
||||
{
|
||||
string localFileName = Path.Combine(Path.GetDirectoryName(processFileName) ?? string.Empty, executable);
|
||||
if (File.Exists(localFileName))
|
||||
{
|
||||
return localFileName;
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New("Unable to locate ErsatzTV.Scanner executable");
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,14 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public CreateLocalLibraryHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
return await LanguageExtensions.Apply(validation, localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
|
||||
@@ -44,7 +44,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if (_entityLocker.LockLibrary(localLibrary.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
}
|
||||
|
||||
return ProjectToViewModel(localLibrary);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -13,18 +14,21 @@ namespace ErsatzTV.Application.Libraries;
|
||||
public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public MoveLocalLibraryPathHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MoveLocalLibraryPathHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -57,7 +61,10 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
foreach (MediaItem mediaItem in maybeMediaItem)
|
||||
{
|
||||
_logger.LogInformation("Moving item at {Path}", await GetPath(dbContext, mediaItem));
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { mediaItem });
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<MediaItem> { mediaItem });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,16 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateLocalLibraryHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_searchIndex = searchIndex;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
@@ -70,7 +70,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if ((toAdd.Count > 0 || toRemove.Count > 0) && _entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
|
||||
return ProjectToViewModel(existing);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record GetExternalCollections : IRequest<List<LibraryViewModel>>;
|
||||
@@ -0,0 +1,33 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollections, List<LibraryViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetExternalCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<LibraryViewModel>> Handle(
|
||||
GetExternalCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<int> mediaSourceIds = await dbContext.EmbyMediaSources
|
||||
.Filter(ems => ems.Libraries.Any(l => ((EmbyLibrary)l).ShouldSyncItems))
|
||||
.Map(ems => ems.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return mediaSourceIds.Map(
|
||||
id => new LibraryViewModel(
|
||||
"Emby",
|
||||
0,
|
||||
"Collections",
|
||||
0,
|
||||
id))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record LogEntryViewModel(
|
||||
int Id,
|
||||
DateTime Timestamp,
|
||||
DateTimeOffset Timestamp,
|
||||
LogEventLevel Level,
|
||||
string Exception,
|
||||
string Message);
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
internal static class Mapper
|
||||
internal partial class Mapper
|
||||
{
|
||||
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry)
|
||||
[GeneratedRegex(@"(.*)\[(DBG|INF|WRN|ERR|FTL)\](.*)")]
|
||||
private static partial Regex LogEntryRegex();
|
||||
|
||||
internal static Option<LogEntryViewModel> ProjectToViewModel(string line)
|
||||
{
|
||||
string message = logEntry.RenderedMessage;
|
||||
if (!string.IsNullOrWhiteSpace(logEntry.Properties))
|
||||
Match match = LogEntryRegex().Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
foreach (KeyValuePair<string, JToken> property in JObject.Parse(logEntry.Properties))
|
||||
{
|
||||
var token = $"{{{property.Key}}}";
|
||||
if (message.Contains(token))
|
||||
{
|
||||
message = message.Replace(token, property.Value.ToString());
|
||||
}
|
||||
|
||||
var destructureToken = $"{{@{property.Key}}}";
|
||||
if (message.Contains(destructureToken))
|
||||
{
|
||||
message = message.Replace(destructureToken, property.Value.ToString());
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse(logEntry.Level, out LogEventLevel level))
|
||||
var timestamp = DateTimeOffset.Parse(match.Groups[1].Value);
|
||||
LogEventLevel level = match.Groups[2].Value switch
|
||||
{
|
||||
level = LogEventLevel.Debug;
|
||||
}
|
||||
"FTL" => LogEventLevel.Fatal,
|
||||
"ERR" => LogEventLevel.Error,
|
||||
"WRN" => LogEventLevel.Warning,
|
||||
"INF" => LogEventLevel.Information,
|
||||
_ => LogEventLevel.Debug
|
||||
};
|
||||
|
||||
return new LogEntryViewModel(
|
||||
logEntry.Id,
|
||||
logEntry.Timestamp,
|
||||
level,
|
||||
logEntry.Exception,
|
||||
message);
|
||||
return new LogEntryViewModel(timestamp, level, match.Groups[3].Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using System.Linq.Expressions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record GetRecentLogEntries(int PageNum, int PageSize) : IRequest<PagedLogEntriesViewModel>
|
||||
public record GetRecentLogEntries(int PageNum, int PageSize, string Filter) : IRequest<PagedLogEntriesViewModel>
|
||||
{
|
||||
public Expression<Func<LogEntry, object>> SortExpression { get; set; }
|
||||
public Option<bool> SortDescending { get; set; }
|
||||
public Expression<Func<LogEntryViewModel, object>> SortExpression { get; init; }
|
||||
public Option<bool> SortDescending { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,40 +1,63 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using static ErsatzTV.Application.Logs.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, PagedLogEntriesViewModel>
|
||||
{
|
||||
private readonly IDbContextFactory<LogContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public GetRecentLogEntriesHandler(IDbContextFactory<LogContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
public GetRecentLogEntriesHandler(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
|
||||
|
||||
public async Task<PagedLogEntriesViewModel> Handle(
|
||||
public Task<PagedLogEntriesViewModel> Handle(
|
||||
GetRecentLogEntries request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using LogContext logContext = _dbContextFactory.CreateDbContext();
|
||||
int count = await logContext.LogEntries.CountAsync(cancellationToken);
|
||||
// get most recent file
|
||||
string logFileName = _localFileSystem.ListFiles(FileSystemLayout.LogsFolder)
|
||||
.OrderDescending()
|
||||
.FirstOrDefault();
|
||||
|
||||
IOrderedQueryable<LogEntry> ordered = logContext.LogEntries
|
||||
.OrderByDescending(le => le.Id);
|
||||
|
||||
foreach (bool descending in request.SortDescending)
|
||||
if (logFileName is not null)
|
||||
{
|
||||
ordered = descending
|
||||
? logContext.LogEntries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Id)
|
||||
: logContext.LogEntries.OrderBy(request.SortExpression).ThenByDescending(le => le.Id);
|
||||
IQueryable<LogEntryViewModel> entries = ReadFrom(logFileName)
|
||||
.Bind(line => ProjectToViewModel(line))
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Filter))
|
||||
{
|
||||
entries = entries.Filter(
|
||||
le => le.Level.ToString().Contains(request.Filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
le.Message.Contains(request.Filter, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
int count = entries.Count();
|
||||
|
||||
IOrderedQueryable<LogEntryViewModel> ordered = request.SortDescending.Match(
|
||||
descending => descending
|
||||
? entries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Timestamp)
|
||||
: entries.OrderBy(request.SortExpression).ThenByDescending(le => le.Timestamp),
|
||||
() => entries.OrderByDescending(le => le.Timestamp));
|
||||
|
||||
var page = ordered
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
return new PagedLogEntriesViewModel(count, page).AsTask();
|
||||
}
|
||||
|
||||
List<LogEntryViewModel> page = await ordered
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
return new PagedLogEntriesViewModel(0, new List<LogEntryViewModel>()).AsTask();
|
||||
}
|
||||
|
||||
return new PagedLogEntriesViewModel(count, page);
|
||||
private static IEnumerable<string> ReadFrom(string file)
|
||||
{
|
||||
using FileStream fs = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(fs);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public record DeleteOrphanedSubtitles : IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,42 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class DeleteOrphanedSubtitlesHandler : IRequestHandler<DeleteOrphanedSubtitles, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public DeleteOrphanedSubtitlesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteOrphanedSubtitles request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
IEnumerable<int> toDelete = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT S.Id FROM Subtitle S
|
||||
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");
|
||||
|
||||
foreach (int id in toDelete)
|
||||
{
|
||||
await dbContext.Connection.ExecuteAsync("DELETE FROM Subtitle WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -8,13 +9,16 @@ namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public EmptyTrashHandler(
|
||||
IClient client,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_client = client;
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
@@ -39,7 +43,7 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
|
||||
|
||||
foreach (string type in types)
|
||||
{
|
||||
SearchResult result = await _searchIndex.Search($"type:{type} AND (state:FileNotFound)", 0, 0);
|
||||
SearchResult result = _searchIndex.Search(_client, $"type:{type} AND (state:FileNotFound)", 0, 0);
|
||||
ids.AddRange(result.Items.Map(i => i.Id));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public record ReleaseMemory(bool ForceAggressive) : IRequest, IBackgroundServiceRequest
|
||||
{
|
||||
public DateTimeOffset RequestTime = DateTimeOffset.Now;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
|
||||
{
|
||||
private static long _lastRelease;
|
||||
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILogger<ReleaseMemoryHandler> _logger;
|
||||
|
||||
public ReleaseMemoryHandler(
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
ILogger<ReleaseMemoryHandler> logger)
|
||||
{
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task Handle(ReleaseMemory request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!request.ForceAggressive && _lastRelease > request.RequestTime.Ticks)
|
||||
{
|
||||
// we've already released since the request was created, so don't bother
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.SessionWorkers.Any() || FFmpegProcess.ProcessCount > 0;
|
||||
if (request.ForceAggressive || !hasActiveWorkers)
|
||||
{
|
||||
_logger.LogDebug("Starting aggressive garbage collection");
|
||||
GC.Collect(2, GCCollectionMode.Aggressive, true, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Starting garbage collection");
|
||||
GC.Collect(2, GCCollectionMode.Forced, false);
|
||||
}
|
||||
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
_logger.LogDebug("Completed garbage collection");
|
||||
Interlocked.Exchange(ref _lastRelease, DateTimeOffset.Now.Ticks);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -17,12 +18,13 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
|
||||
public AddTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<AddTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
@@ -59,7 +61,7 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
|
||||
private async Task<Either<BaseError, Unit>> DoAdd(Parameters parameters)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
return await TraktApiClient.GetUserList(parameters.User, parameters.List)
|
||||
.BindT(list => SaveList(dbContext, list))
|
||||
|
||||
@@ -20,7 +20,7 @@ public class CreateCollectionHandler :
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => PersistCollection(dbContext, c));
|
||||
return await validation.Apply(c => PersistCollection(dbContext, c));
|
||||
}
|
||||
|
||||
private static async Task<MediaCollectionViewModel> PersistCollection(
|
||||
|
||||
@@ -20,7 +20,7 @@ public class CreateMultiCollectionHandler :
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => PersistCollection(dbContext, c));
|
||||
return await validation.Apply(c => PersistCollection(dbContext, c));
|
||||
}
|
||||
|
||||
private static async Task<MultiCollectionViewModel> PersistCollection(
|
||||
|
||||
@@ -20,7 +20,7 @@ public class CreateSmartCollectionHandler :
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => PersistCollection(dbContext, c));
|
||||
return await validation.Apply(c => PersistCollection(dbContext, c));
|
||||
}
|
||||
|
||||
private static async Task<SmartCollectionViewModel> PersistCollection(
|
||||
|
||||
@@ -20,7 +20,7 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, Collection> validation = await CollectionMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, Collection collection)
|
||||
|
||||
@@ -20,7 +20,7 @@ public class DeleteMultiCollectionHandler : IRequestHandler<DeleteMultiCollectio
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, MultiCollection> validation = await MultiCollectionMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, MultiCollection multiCollection)
|
||||
|
||||
@@ -20,7 +20,7 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -14,20 +15,23 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public DeleteTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<DeleteTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
@@ -38,8 +42,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
|
||||
}
|
||||
@@ -56,7 +59,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
dbContext.TraktLists.Remove(traktList);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, mediaItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, mediaItemIds);
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -18,11 +19,17 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||
|
||||
public MatchTraktListItemsHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MatchTraktListItemsHandler> logger,
|
||||
IEntityLocker entityLocker) : base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
IEntityLocker entityLocker) : base(
|
||||
traktApiClient,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
@@ -13,18 +14,21 @@ namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public abstract class TraktCommandBase
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
protected TraktCommandBase(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
TraktApiClient = traktApiClient;
|
||||
}
|
||||
@@ -83,7 +87,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (TraktListItem existing in toUpdate)
|
||||
{
|
||||
Option<TraktListItem> maybeIncoming = list.Items.Find(i => i.TraktId == existing.TraktId);
|
||||
Option<TraktListItem> maybeIncoming = items
|
||||
.Filter(i => i.TraktId == existing.TraktId)
|
||||
.Map(i => ProjectItem(list, i))
|
||||
.HeadOrNone();
|
||||
|
||||
foreach (TraktListItem incoming in maybeIncoming)
|
||||
{
|
||||
existing.Kind = incoming.Kind;
|
||||
@@ -158,7 +166,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids.ToList());
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids.ToList());
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
@@ -204,7 +212,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int movieId in maybeMovieByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle);
|
||||
return movieId;
|
||||
}
|
||||
|
||||
@@ -217,11 +225,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int movieId in maybeMovieByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle);
|
||||
return movieId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
@@ -239,7 +247,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int showId in maybeShowByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle);
|
||||
return showId;
|
||||
}
|
||||
|
||||
@@ -252,11 +260,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int showId in maybeShowByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt show {Title} by title/year", item.Title);
|
||||
// _logger.LogDebug("Located trakt show {Title} by title/year", item.Title);
|
||||
return showId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
@@ -274,7 +282,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int seasonId in maybeSeasonByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle);
|
||||
return seasonId;
|
||||
}
|
||||
|
||||
@@ -288,11 +296,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int seasonId in maybeSeasonByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle);
|
||||
return seasonId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
@@ -310,7 +318,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int episodeId in maybeEpisodeByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle);
|
||||
return episodeId;
|
||||
}
|
||||
|
||||
@@ -325,11 +333,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int episodeId in maybeEpisodeByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle);
|
||||
return episodeId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user