Compare commits
162 Commits
v0.8.2-bet
...
v0.8.6-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd6673da82 | ||
|
|
8113827802 | ||
|
|
4e56117e0e | ||
|
|
7702999b9a | ||
|
|
14a707a4e2 | ||
|
|
340ab61a26 | ||
|
|
d91d991251 | ||
|
|
3ce341eee5 | ||
|
|
476fe991b6 | ||
|
|
39df3504fc | ||
|
|
60bb369d0c | ||
|
|
aae704f3a5 | ||
|
|
a45583d77a | ||
|
|
923b36604c | ||
|
|
b21d16b0f1 | ||
|
|
a5aaceeee5 | ||
|
|
e52d45fcf8 | ||
|
|
21d39bc26f | ||
|
|
233a1c228a | ||
|
|
56988be57b | ||
|
|
aded03d962 | ||
|
|
2119c88c97 | ||
|
|
a5d83a970a | ||
|
|
986785d863 | ||
|
|
087901d177 | ||
|
|
70c4036dc9 | ||
|
|
955add1efd | ||
|
|
99cd01f73b | ||
|
|
ef29e8c5a1 | ||
|
|
3b4c993530 | ||
|
|
bcc58bd668 | ||
|
|
6957a76156 | ||
|
|
4bafc316cc | ||
|
|
35817f09ac | ||
|
|
f4520a5520 | ||
|
|
3a906816fc | ||
|
|
707292c50f | ||
|
|
71e9ea867a | ||
|
|
c490832f66 | ||
|
|
e00568cc23 | ||
|
|
356e0f101a | ||
|
|
1f6e843a26 | ||
|
|
9587692486 | ||
|
|
f8c4f44216 | ||
|
|
d55ba235bf | ||
|
|
60b479e330 | ||
|
|
b866d07911 | ||
|
|
93db79f8c4 | ||
|
|
a15854d0ad | ||
|
|
c743d07425 | ||
|
|
8c3b8e81ca | ||
|
|
49050a57d2 | ||
|
|
49c53c5c96 | ||
|
|
1510c56e69 | ||
|
|
3ec610d65f | ||
|
|
69f9b6f137 | ||
|
|
08837bda80 | ||
|
|
9089e2ee04 | ||
|
|
abed22b560 | ||
|
|
e0f9ab4b88 | ||
|
|
231a214223 | ||
|
|
82bfa8019e | ||
|
|
d9bbe4df1b | ||
|
|
e0aa44d41b | ||
|
|
3d99c2593d | ||
|
|
d6dfc1edaa | ||
|
|
7d5cd229d4 | ||
|
|
cd0219c5c3 | ||
|
|
4cf8b83de4 | ||
|
|
6923b25177 | ||
|
|
5dce905b8e | ||
|
|
46c26b5ea7 | ||
|
|
7fffc8cf63 | ||
|
|
18deff0b83 | ||
|
|
16007a888e | ||
|
|
7eb1227ba4 | ||
|
|
1d1d5bf9bc | ||
|
|
45c04366c9 | ||
|
|
60b3bc92f4 | ||
|
|
12234c3e21 | ||
|
|
d37ce2d38a | ||
|
|
6f49233864 | ||
|
|
a67a6047c1 | ||
|
|
33f67b88f0 | ||
|
|
b88deaafe5 | ||
|
|
83fc3081d8 | ||
|
|
15d4b0f82b | ||
|
|
88fac0de04 | ||
|
|
4805d0d40f | ||
|
|
ef3b941a39 | ||
|
|
a59f71039c | ||
|
|
1ad42fffb1 | ||
|
|
2ce8db9e01 | ||
|
|
c409fd8b47 | ||
|
|
907b8074f1 | ||
|
|
adbd0bcec0 | ||
|
|
2c4379886a | ||
|
|
caef4a139e | ||
|
|
dcbe4837bf | ||
|
|
5e530b9301 | ||
|
|
2a28bf68bf | ||
|
|
f39eac97c0 | ||
|
|
9fd6589831 | ||
|
|
e2a516f5e8 | ||
|
|
64502315a3 | ||
|
|
56bc58fce9 | ||
|
|
0330b9326d | ||
|
|
6708d6b4d7 | ||
|
|
c18be5559b | ||
|
|
18ed20e203 | ||
|
|
965c7d0eac | ||
|
|
545bf1b775 | ||
|
|
bb299d4ee7 | ||
|
|
0e6c7d2bc3 | ||
|
|
576f0cd7e7 | ||
|
|
9471cb55dd | ||
|
|
3a84af1626 | ||
|
|
3d3bb64844 | ||
|
|
8fc1f36638 | ||
|
|
1823a5bae5 | ||
|
|
fc871e6f74 | ||
|
|
24780cbe84 | ||
|
|
c6ed258021 | ||
|
|
7586647b73 | ||
|
|
d91e945124 | ||
|
|
9dabffbac1 | ||
|
|
d310b5c09d | ||
|
|
ba48b3a676 | ||
|
|
d8a51b5d6d | ||
|
|
97674cff89 | ||
|
|
4820615308 | ||
|
|
1ddf27ce88 | ||
|
|
cd98a89acd | ||
|
|
a2a6afc3e3 | ||
|
|
dfaba8c7b0 | ||
|
|
5d11a6b46f | ||
|
|
b95a89b11f | ||
|
|
948b3735bd | ||
|
|
5ecf271773 | ||
|
|
b287c0d6ec | ||
|
|
b667659c05 | ||
|
|
22d3025e8e | ||
|
|
8f5b181372 | ||
|
|
f5060522aa | ||
|
|
14a88bd225 | ||
|
|
0550c60a78 | ||
|
|
d3bdcf9bc4 | ||
|
|
714f68a887 | ||
|
|
17bed524f2 | ||
|
|
c3fe263978 | ||
|
|
5291832e6c | ||
|
|
b39dd693f0 | ||
|
|
46bf9ef990 | ||
|
|
bc845b1327 | ||
|
|
3ab8e5bc3a | ||
|
|
e8bc051f73 | ||
|
|
b008fcfd85 | ||
|
|
547db5fb51 | ||
|
|
58fae1b0cc | ||
|
|
694b6bbd91 | ||
|
|
e0f8b7d7ae | ||
|
|
b16215fcd6 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2023.2.0",
|
||||
"version": "2023.3.3",
|
||||
"commands": [
|
||||
"jb"
|
||||
]
|
||||
|
||||
58
.github/workflows/artifacts.yml
vendored
58
.github/workflows/artifacts.yml
vendored
@@ -47,19 +47,9 @@ jobs:
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.8.28
|
||||
with:
|
||||
working-directory: ErsatzTV/client-app
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -68,7 +58,7 @@ jobs:
|
||||
run: dotnet restore -r "${{ matrix.target}}"
|
||||
|
||||
- name: Import Code-Signing Certificates
|
||||
uses: Apple-Actions/import-codesign-certs@v1
|
||||
uses: Apple-Actions/import-codesign-certs@v2
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.apple_developer_certificate_p12_base64 }}
|
||||
p12-password: ${{ secrets.apple_developer_certificate_password }}
|
||||
@@ -83,8 +73,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Bundle
|
||||
shell: bash
|
||||
@@ -118,12 +108,8 @@ jobs:
|
||||
- name: Notarize
|
||||
shell: bash
|
||||
run: |
|
||||
brew tap mitchellh/gon
|
||||
brew install mitchellh/gon/gon
|
||||
gon -log-level=debug -log-json ./gon.json
|
||||
env:
|
||||
AC_USERNAME: ${{ secrets.ac_username }}
|
||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
||||
xcrun notarytool submit ErsatzTV.dmg --apple-id "${{ secrets.ac_username }}" --password "${{ secrets.ac_password }}" --team-id 32MB98Q32R --wait
|
||||
xcrun stapler staple ErsatzTV.dmg
|
||||
|
||||
- name: Cleanup
|
||||
shell: bash
|
||||
@@ -177,25 +163,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.8.28
|
||||
with:
|
||||
working-directory: ErsatzTV/client-app
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -208,7 +178,7 @@ jobs:
|
||||
id: downloadffmpeg
|
||||
name: Download ffmpeg
|
||||
with:
|
||||
url: "https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.7z"
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/6.1-working-cuvid/ffmpeg-6.1-working-cuvid.7z"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Build
|
||||
@@ -220,8 +190,8 @@ jobs:
|
||||
|
||||
# Build everything
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
# Build Windows launcher
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
@@ -245,9 +215,6 @@ jobs:
|
||||
|
||||
# Delete output directory
|
||||
rm -r "$release_name"
|
||||
env:
|
||||
AC_USERNAME: ${{ secrets.ac_username }}
|
||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
@@ -259,6 +226,7 @@ jobs:
|
||||
assets: |
|
||||
*${{ matrix.target }}.zip
|
||||
*${{ matrix.target }}.tar.gz
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
|
||||
12
.github/workflows/docker.yml
vendored
12
.github/workflows/docker.yml
vendored
@@ -54,21 +54,21 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
if: ${{ matrix.qemu == true }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: docker-buildx
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username }}
|
||||
password: ${{ secrets.docker_hub_access_token }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
if: ${{ matrix.name == 'arm64' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
|
||||
27
.github/workflows/issue-stale.yml
vendored
Normal file
27
.github/workflows/issue-stale.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: 'Close stale issues'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
ascending: true
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 500
|
||||
exempt-issue-labels: 'regression,security,roadmap,future,feature,enhancement,confirmed'
|
||||
stale-issue-label: 'stale'
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
|
||||
|
||||
If you have any questions you can use one of several ways to [contact us](https://ersatztv.org).
|
||||
close-issue-message: |-
|
||||
This issue was closed due to inactivity.
|
||||
23
.github/workflows/pr.yml
vendored
23
.github/workflows/pr.yml
vendored
@@ -9,14 +9,9 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -31,7 +26,7 @@ jobs:
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
|
||||
- name: Build Windows
|
||||
run: |
|
||||
@@ -44,9 +39,9 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -61,7 +56,7 @@ jobs:
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-11
|
||||
steps:
|
||||
@@ -72,9 +67,9 @@ jobs:
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -89,4 +84,4 @@ jobs:
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
|
||||
22
.github/workflows/vue-lint.yml
vendored
22
.github/workflows/vue-lint.yml
vendored
@@ -1,22 +0,0 @@
|
||||
name: Lint VueJS Files on PR Request
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
vue-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Checkout the current repo
|
||||
- name: Checkout current repository
|
||||
uses: actions/checkout@v4
|
||||
# Setup NodeJS version 16
|
||||
- name: Setup NodeJS V16.x.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
# CD into the current client directory and lint and build the client
|
||||
- name: Lint and Build the client
|
||||
run: |
|
||||
cd ./ErsatzTV/client-app/
|
||||
npm ci --no-optional
|
||||
npm run lint
|
||||
npm run build --if-present
|
||||
204
CHANGELOG.md
204
CHANGELOG.md
@@ -5,6 +5,204 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.6-beta] - 2024-04-03
|
||||
### Added
|
||||
- Add `show_studio` and `show_content_rating` to search index for seasons and episodes
|
||||
- Add two new global subtitle settings:
|
||||
- `Use embedded subtitles`
|
||||
- Default value: `true`
|
||||
- When disabled, embedded subtitles will not be considered for extraction (text subtitles), or playback (all embedded subtitles)
|
||||
- `Extract and use embedded (text) subtitles`
|
||||
- Default value: `false`
|
||||
- When enabled, embedded text subtitles will be periodically extracted, and considered for playback
|
||||
- Add `sub_language` and `sub_language_tag` fields to search index
|
||||
- Add `/iptv` request logging in its own log category at debug level
|
||||
- Add channel guide (XMLTV) template system
|
||||
- Templates should be copied from `_channel.sbntxt`, `_movie.sbntxt`, `_episode.sbntxt`, `_musicVideo.sbntxt`, `_song.sbntxt`, or `_otherVideo.sbntxt` which are located in the config subfolder `templates/channel-guide`
|
||||
- Copy the file, remove the leading underscore from the name, and only make edits to the copied file
|
||||
- The default templates will be extracted and overwritten every time ErsatzTV is started
|
||||
- The templates use [scribian](https://github.com/scriban/scriban/tree/master/doc) template syntax
|
||||
- The templates contain comments describing which fields are available for use in the templates
|
||||
- Add *experimental* and *incomplete* `Images` library kind
|
||||
- Image libraries have fallback metadata added like Other Video libraries (every folder is a tag)
|
||||
- Image library items currently default to a duration of 15 seconds
|
||||
- The `Media` > `Images` page can be used to configure image durations at a folder level
|
||||
- Child folders with unset durations will inherit the closest ancestor's duration
|
||||
- Add *experimental* new streaming mode `HLS Segmenter V2`
|
||||
- In my initial testing, this streaming mode produces significantly fewer playback warnings/errors
|
||||
- If it tests well for others, it *may* replace the current `HLS Segmenter` in a future release
|
||||
- Add setting to change XMLTV data from `Local` time zone to `UTC`
|
||||
- This is needed because some clients (incorrectly) ignore time zone specifier and require UTC times
|
||||
- Support `.ogv` video files in local libraries
|
||||
|
||||
### Fixed
|
||||
- Fix antiforgery error caused by reusing existing browser tabs across docker container restarts
|
||||
- Data protection keys will now be persisted under ErsatzTV's config folder instead of being recreated at startup
|
||||
- Fix bug updating/replacing Jellyfin movies
|
||||
- A deep scan can be used to fix all movies, otherwise any future updates made to JF movies will correctly sync to ETV
|
||||
- Automatically generate JWT tokens to allow channel previews of protected streams
|
||||
- Fix bug applying music video fallback metadata
|
||||
- Fix playback of media items with no audio streams
|
||||
- Fix timestamp continuity in `HLS Segmenter` sessions
|
||||
- This should make *some* clients happier
|
||||
- Fix `Other Video`, `Song` and `Image` fallback metadata tags to always include parent folder (folder added to library)
|
||||
- Allow playback of items with any positive duration, including less than one second
|
||||
- Fix VAAPI transcoding of OTA content containing A53 CC data
|
||||
- Fix AV1 software decoder priority (`libdav1d`, `libaom-av1`, `av1`)
|
||||
- Fix some stream failures caused by loudnorm filter
|
||||
- Fix multi-collection editor improperly disabling collections/smart collections that haven't already been added to the multi-collection
|
||||
- Fix path replacement logic when media server paths use inconsistent casing (e.g. `\\SERVERNAME` AND `\\ServerName`)
|
||||
- Fix *many* search queries, including actors with the name `Will`
|
||||
- Fix sqlite `database is locked` error that would crash ETV on startup after search index corruption
|
||||
- Fix bug where replacing files in Plex would be missed by subsequent ETV library scans
|
||||
- This fix will require a one-time re-scan of each Plex library in full
|
||||
- After the initial full scan, incremental scans will behave as normal
|
||||
- Fix edge case where some local episodes, music videos, other videos, songs, images would not automatically be restored from trash
|
||||
- Fix `MPEG-TS` playback when JWT tokens are enabled for streaming endpoints
|
||||
|
||||
### Changed
|
||||
- Log search index updates under scanner category at debug level, to indicate a potential cause for the UI being out of date
|
||||
- Batch search index updates to keep pace with library scans
|
||||
- Previously, search index updates would slowly process over minutes/hours after library scans completed
|
||||
- Search index updates should now complete at the same time as library scans
|
||||
- Do not unnecessarily update the search index during media server library scans
|
||||
- Use different library for reading song metadata that supports multiple tag entries
|
||||
- Update `/iptv` routing to make UI completely inaccessible from that path prefix
|
||||
- Use CUDA 11 instead of CUDA 12 in NVIDIA docker image to significantly lower required driver version
|
||||
- Allow block durations with 5-minute increments (e.g., 5 min, 10 min, 15 min, etc.)
|
||||
|
||||
## [0.8.5-beta] - 2024-01-30
|
||||
### Added
|
||||
- Respect browser's `Accept-Language` header for date time display
|
||||
- Add new schedule item setting `Fill With Group Mode`
|
||||
- This setting is only available when a `Collection`, `Multi-Collection` or `Smart Collection` is scheduled with `Duration` or `Multiple` playout modes
|
||||
- Use this setting when you want to schedule a collection containing groups (show or artists), with only videos from a single group (show or artist) being used in each rotation
|
||||
- The options are `None`, `Ordered Groups` and `Shuffled Groups`:
|
||||
- `None`: no change to scheduling behavior - all groups (shows and artists) will be shuffled/ordered together
|
||||
- `Ordered Groups`: each time this item is scheduled, the entire `Duration` or `Multiple` will be filled with a single group, and the groups will rotate in a fixed order
|
||||
- `Shuffled Groups`: each time this item is scheduled, the entire `Duration` or `Multiple` will be filled with a single group, and the groups will rotate in a shuffled order
|
||||
- Add new playout type `External Json`
|
||||
- Use this playout type when you want to manage the channel schedule using DizqueTV
|
||||
- You must point ErsatzTV to the channel number json file from DizqueTV, e.g. `channels/1.json`
|
||||
- For playback, ErsatzTV will first check for the appropriate media file file locally
|
||||
- If found, ErsatzTV will run ffprobe to get statistics immediately before streaming from disk
|
||||
- When local files are unavailable, ErsatzTV must be logged into the same Plex server as DizqueTV
|
||||
- ErsatzTV will ask Plex for statistics immediately before streaming from Plex
|
||||
- Add new *experimental* playout type `Block`
|
||||
- **This playout type is under active development and updates may reset or delete related playout data**
|
||||
- Many planned features are missing, incomplete, or result in errors. This is expected.
|
||||
- Block playouts consist of:
|
||||
- `Blocks` - ordered list of items to play within the specified duration
|
||||
- `Templates` - a generic "day" that consists of blocks scheduled at specific times
|
||||
- `Playout Templates` - templates to schedule using the specified criteria. Only one template will be selected each day
|
||||
- Much more to come on this feature as development continues
|
||||
- Show chapter markers in movie and episode media info
|
||||
- Add two new API endpoints for interacting with transcoding sessions (MPEG-TS and HLS Segmenter):
|
||||
- GET `/api/sessions`
|
||||
- Show brief info about all active sessions
|
||||
- DELETE `/api/session/{channel-number}`
|
||||
- Stop the session for the given channel number
|
||||
- Add channel preview (web-based video player)
|
||||
- Channels MUST use `H264` video format and `AAC` audio format
|
||||
- Channels MUST use `MPEG-TS` or `HLS Segmenter` streaming modes
|
||||
- Since `MPEG-TS` uses `HLS Segmenter` under the hood, the preview player will use `HLS Segmenter`, so it's not 100% equivalent, but it should be representative
|
||||
- Add button to stop transcoding session for each channel that has an active session
|
||||
- Add more log levels to `Settings` page, allowing more specific debug logging as needed
|
||||
- Default Minimum Log Level (applies when no other categories/level overrides match)
|
||||
- Scanning Minimum Log Level
|
||||
- Scheduling Minimum Log Level
|
||||
- Streaming Minimum Log Level
|
||||
|
||||
### Fixed
|
||||
- Fix error loading path replacements when using MySql
|
||||
- Fix tray icon shortcut to open logs folder on Windows
|
||||
- Unlock playout when playout build fails
|
||||
- Ignore errors deleting old HLS segments; this should improve stream reliability
|
||||
- Update show year when changed within Plex
|
||||
- Fix crop scale behavior with NVIDIA, QSV acceleration
|
||||
- Fix bug that corrupted uploaded images (watermarks, channel logos)
|
||||
- Re-uploading images should fix them
|
||||
- Recreate XMLTV channel list (including logos) when channels are edited in ErsatzTV
|
||||
- This bug caused the ErsatzTV logo to be used instead of channel logos in some cases
|
||||
- Update drop down search results in main search bar when items are created/edited/removed
|
||||
- Fix green line at bottom of video when NVIDIA accel is used with intermittent watermark
|
||||
- Fix error starting streaming session when subtitles are still being extracted for the current item
|
||||
|
||||
### Changed
|
||||
- Upgrade from .NET 7 to .NET 8
|
||||
- In schedule items, disambiguate seasons from shows with the same title by including show year
|
||||
- Old format: `Show Title (Season Number)`
|
||||
- New format: `Show Title (Show Year) - Season Number`
|
||||
- Remove FFmpeg Profile `Normalize Loudness` option `dynaudnorm` as it often caused streams to fail to start
|
||||
- Disable loudness normalization by default in new FFmpeg Profiles
|
||||
- Use AAC audio format by default in new FFmpeg Profiles
|
||||
|
||||
## [0.8.4-beta] - 2023-12-02
|
||||
### Fixed
|
||||
- Fix playout builder crash with improperly configured pad filler preset
|
||||
- Properly validate filler preset mode pad to require `filler pad to nearest minute` value
|
||||
- Fix bug where previously-synchronized collection tags would disappear
|
||||
- This bug affected Jellyfin, Emby and Plex collections
|
||||
- Fix detection of AMF hardware acceleration on Windows
|
||||
|
||||
## [0.8.3-beta] - 2023-11-22
|
||||
### Added
|
||||
- Add `Scaling Behavior` option to FFmpeg Profile
|
||||
- `Scale and Pad`: the default behavior and will maintain aspect ratio of all content
|
||||
- `Stretch`: a new mode that will NOT maintain aspect ratio when normalizing source content to the desired resolution
|
||||
- `Crop`: a new mode that will scale beyond the desired resolution (maintaining aspect ratio), and crop to desired resolution
|
||||
- **This mode does NOT detect black and intelligently crop**
|
||||
- The goal is to fill the canvas by over-scaling and cropping, instead of minimally scaling and padding
|
||||
- Include `inputstream.ffmpegdirect` properties in channels.m3u when requested by Kodi
|
||||
- Log playout item title and path when starting a stream
|
||||
- This will help with media server libraries where the URL passed to ffmpeg doesn't indicate which file is streaming
|
||||
- Add QSV Capabilities to Troubleshooting page
|
||||
- Add `language_tag` and `seconds` fields to search index
|
||||
- Allow synchronizing Plex `TV Show` libraries that use `Personal Media Shows` agent
|
||||
- Include Noto CJK Fonts in docker images to support those characters in generated subtitles like songs and music video credits
|
||||
- Support show fallback metadata with folder names like `Show.Name(1992)`
|
||||
|
||||
### Fixed
|
||||
- Fix playout bug that caused some schedule items with fixed start times to be pushed to the next day
|
||||
- Fix playout bug that prevented padded durations from fitting within a schedule item of the same duration
|
||||
- For example, filler that padded to 30 minutes would often not fit in a 30 minute duration schedule item
|
||||
- Fix VAAPI transcoding 8-bit source content to 10-bit
|
||||
- Fix NVIDIA subtitle scaling when `scale_npp` filter is unavailable
|
||||
- Remove ffmpeg and ffprobe as required dependencies for scanning media server libraries
|
||||
- Note that ffmpeg is still *always* required for playback to work
|
||||
- Fix PGS subtitle pixel format with Intel VAAPI
|
||||
- Fix some cases where `Copy` button would fail to copy to clipboard
|
||||
- Fix some cases where ffmpeg process would remain running after properly closing ErsatzTV
|
||||
- Fix QSV HLS segment duration
|
||||
- This behavior caused extremely slow QSV stream starts
|
||||
- Fix displaying multiple languages in UI for movies, artists, shows
|
||||
- Fix MySQL queries that could fail during media server library scans
|
||||
- Fix scanning Jellyfin libraries when library options and/or path infos are not returned from Jellyfin API
|
||||
- Fix error indexing music videos in `File Not Found` state
|
||||
- Fix bug scheduling duration filler when filler collection contains item with zero duration
|
||||
- Fix bug displaying television seasons for shows that have no year metadata
|
||||
|
||||
### Changed
|
||||
- Upgrade ffmpeg to 6.1, which is now *required* for all installs
|
||||
- Use new ffmpeg throttling method to minimize cpu/gpu use without impacting audio normalization
|
||||
- Change FFmpeg Profile `Normalize Loudness` setting from checkbox to dropdown
|
||||
- `Off`: do not normalize loudness
|
||||
- `loudnorm`: use `loudnorm` filter to normalize loudness (generally higher CPU use)
|
||||
- `dynaudnorm`: use `dynaudnorm` filter to normalize loudness (generally lower CPU use)
|
||||
- Jellyfin collection scanning will no longer happen after every (automatic or forced) library scan
|
||||
- Automatic/periodic scans will check collections one time after all libraries have been scanned
|
||||
- There is a new table in the `Media` > `Libraries` page with a button to manually re-scan Jellyfin collections as needed
|
||||
- In FFmpeg Profile editor, only display hardware acceleration kinds that are supported by the configured ffmpeg
|
||||
- Test QSV acceleration if configured, and fallback to software mode if test fails
|
||||
- Detect QSV capabilities on Linux (supported decoders, encoders)
|
||||
- Use hardware acceleration for error messages/offline messages
|
||||
- Try to parse season number from season folder when Jellyfin does not provide season number
|
||||
- This *may* fix issues where Jellyfin libraries show all season numbers as 0 (specials)
|
||||
- Rework Plex collection scanning
|
||||
- Automatic/periodic scans will check collections one time after all libraries have been scanned
|
||||
- There is a table in the `Media` > `Libraries` page with a button to manually re-scan Plex collections as needed
|
||||
- Plex smart collections will now be synchronized as tags, similar to other Plex collections
|
||||
|
||||
## [0.8.2-beta] - 2023-09-14
|
||||
### Added
|
||||
- Automatically rebuild search index after improper shutdown
|
||||
@@ -1741,7 +1939,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.2-beta...HEAD
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.6-beta...HEAD
|
||||
[0.8.6-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.5-beta...v0.8.6-beta
|
||||
[0.8.5-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.4-beta...v0.8.5-beta
|
||||
[0.8.4-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.3-beta...v0.8.4-beta
|
||||
[0.8.3-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.2-beta...v0.8.3-beta
|
||||
[0.8.2-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.1-beta...v0.8.2-beta
|
||||
[0.8.1-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.0-beta...v0.8.1-beta
|
||||
[0.8.0-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.9-beta...v0.8.0-beta
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -43,10 +43,7 @@ fn main() {
|
||||
None => {}
|
||||
Some(folder) => {
|
||||
fs::create_dir_all(folder).unwrap();
|
||||
let _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("/C")
|
||||
.arg("start")
|
||||
let _ = Command::new("explorer.exe")
|
||||
.arg(folder)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
|
||||
@@ -29,12 +29,11 @@ internal static class Mapper
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
|
||||
return languages
|
||||
.Distinct()
|
||||
.Map(
|
||||
lang => allCultures.Filter(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
|
||||
.Sequence()
|
||||
.Flatten()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record CreateChannel
|
||||
(
|
||||
public record CreateChannel(
|
||||
string Name,
|
||||
string Number,
|
||||
string Group,
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
|
||||
public class CreateChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public CreateChannelHandler(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, CreateChannelResult>> Handle(
|
||||
CreateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||
}
|
||||
|
||||
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||
{
|
||||
await dbContext.Channels.AddAsync(channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await workerChannel.WriteAsync(new RefreshChannelList());
|
||||
return new CreateChannelResult(channel.Id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -12,24 +13,26 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public DeleteChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem)
|
||||
ILocalFileSystem localFileSystem,
|
||||
ISearchTargets searchTargets)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request);
|
||||
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c, cancellationToken));
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||
@@ -37,6 +40,8 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
dbContext.Channels.Remove(channel);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_localFileSystem.FileExists(cacheFile))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,11 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
using Scriban;
|
||||
using Scriban.Runtime;
|
||||
using WebMarkupMin.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
@@ -13,16 +17,19 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RefreshChannelListHandler> _logger;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public RefreshChannelListHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem)
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RefreshChannelListHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken)
|
||||
@@ -31,41 +38,60 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
|
||||
}
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate channel list without template file {File}; please restart ErsatzTV",
|
||||
templateFileName);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var minifier = new XmlMinifier(
|
||||
new XmlMinificationSettings
|
||||
{
|
||||
MinifyWhitespace = true,
|
||||
RemoveXmlComments = true,
|
||||
CollapseTagsWithoutContent = true
|
||||
});
|
||||
|
||||
string text = await File.ReadAllTextAsync(templateFileName, cancellationToken);
|
||||
var template = Template.Parse(text, templateFileName);
|
||||
var templateContext = new XmlTemplateContext();
|
||||
|
||||
await using RecyclableMemoryStream 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))
|
||||
var data = new
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(category);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
ChannelNumber = channel.Number,
|
||||
ChannelName = channel.Name,
|
||||
ChannelCategories = GetCategories(channel.Categories),
|
||||
ChannelHasArtwork = !string.IsNullOrWhiteSpace(channel.ArtworkPath),
|
||||
ChannelArtworkPath = channel.ArtworkPath
|
||||
};
|
||||
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, GetIconUrl(channel));
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
var scriptObject = new ScriptObject();
|
||||
scriptObject.Import(data);
|
||||
templateContext.PushGlobal(scriptObject);
|
||||
|
||||
await xml.WriteEndElementAsync(); // channel
|
||||
string result = await template.RenderAsync(templateContext);
|
||||
|
||||
MarkupMinificationResult minified = minifier.Minify(result);
|
||||
await xml.WriteRawAsync(minified.MinifiedContent);
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
@@ -3,8 +3,7 @@ using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record UpdateChannel
|
||||
(
|
||||
public record UpdateChannel(
|
||||
int ChannelId,
|
||||
string Name,
|
||||
string Number,
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -12,26 +13,19 @@ using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
||||
public class UpdateChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ChannelViewModel>> Handle(
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
|
||||
@@ -78,6 +72,8 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
if (c.SubtitleMode != ChannelSubtitleMode.None)
|
||||
{
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
@@ -85,10 +81,12 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList());
|
||||
|
||||
return ProjectToViewModel(c);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ internal static class Mapper
|
||||
channel.PreferredAudioLanguageCode,
|
||||
GetStreamingMode(channel));
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
|
||||
private static string GetLogo(Channel channel) =>
|
||||
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
@@ -45,6 +48,7 @@ internal static class Mapper
|
||||
StreamingMode.TransportStreamHybrid => "MPEG-TS",
|
||||
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
||||
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(channel))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
return result;
|
||||
}
|
||||
|
||||
if (distinct.Any())
|
||||
if (distinct.Count != 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
|
||||
@@ -3,5 +3,5 @@ using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelGuide
|
||||
(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<Either<BaseError, ChannelGuide>>;
|
||||
public record GetChannelGuide(string Scheme, string Host, string BaseUrl, string AccessToken)
|
||||
: IRequest<Either<BaseError, ChannelGuide>>;
|
||||
|
||||
@@ -2,5 +2,10 @@ using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelPlaylist
|
||||
(string Scheme, string Host, string BaseUrl, string Mode, string AccessToken) : IRequest<ChannelPlaylist>;
|
||||
public record GetChannelPlaylist(
|
||||
string Scheme,
|
||||
string Host,
|
||||
string BaseUrl,
|
||||
string Mode,
|
||||
string UserAgent,
|
||||
string AccessToken) : IRequest<ChannelPlaylist>;
|
||||
|
||||
@@ -20,6 +20,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels,
|
||||
request.UserAgent,
|
||||
request.AccessToken));
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
@@ -33,6 +34,10 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "segmenter-v2":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterV2;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "hls-direct":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
|
||||
result.Add(channel);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelResolution(string ChannelNumber) : IRequest<Option<ResolutionViewModel>>;
|
||||
@@ -0,0 +1,25 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelResolutionHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelResolution, Option<ResolutionViewModel>>
|
||||
{
|
||||
public async Task<Option<ResolutionViewModel>> Handle(
|
||||
GetChannelResolution request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.ThenInclude(ff => ff.Resolution)
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
|
||||
|
||||
return maybeChannel.Map(c => Mapper.ProjectToViewModel(c.FFmpegProfile.Resolution));
|
||||
}
|
||||
}
|
||||
3
ErsatzTV.Application/Channels/ResolutionViewModel.cs
Normal file
3
ErsatzTV.Application/Channels/ResolutionViewModel.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record ResolutionViewModel(int Height, int Width);
|
||||
14
ErsatzTV.Application/Channels/XmlTemplateContext.cs
Normal file
14
ErsatzTV.Application/Channels/XmlTemplateContext.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Net;
|
||||
using Scriban;
|
||||
using Scriban.Parsing;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class XmlTemplateContext : TemplateContext
|
||||
{
|
||||
public override TemplateContext Write(SourceSpan span, object textAsObject)
|
||||
=> base.Write(span, textAsObject is string text ? WebUtility.HtmlEncode(text) : textAsObject);
|
||||
|
||||
public override ValueTask<TemplateContext> WriteAsync(SourceSpan span, object textAsObject)
|
||||
=> base.WriteAsync(span, textAsObject is string text ? WebUtility.HtmlEncode(text) : textAsObject);
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Serilog.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly LoggingLevelSwitch _loggingLevelSwitch;
|
||||
private readonly LoggingLevelSwitches _loggingLevelSwitches;
|
||||
|
||||
public UpdateGeneralSettingsHandler(
|
||||
LoggingLevelSwitch loggingLevelSwitch,
|
||||
LoggingLevelSwitches loggingLevelSwitches,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_loggingLevelSwitch = loggingLevelSwitch;
|
||||
_loggingLevelSwitches = loggingLevelSwitches;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
@@ -24,8 +23,28 @@ public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSetting
|
||||
|
||||
private async Task<Unit> ApplyUpdate(GeneralSettingsViewModel generalSettings)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.MinimumLogLevel);
|
||||
_loggingLevelSwitch.MinimumLevel = generalSettings.MinimumLogLevel;
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.DefaultMinimumLogLevel);
|
||||
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = generalSettings.DefaultMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelScanning,
|
||||
generalSettings.ScanningMinimumLogLevel);
|
||||
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = generalSettings.ScanningMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelScheduling,
|
||||
generalSettings.SchedulingMinimumLogLevel);
|
||||
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = generalSettings.SchedulingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelStreaming,
|
||||
generalSettings.StreamingMinimumLogLevel);
|
||||
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = generalSettings.StreamingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelHttp,
|
||||
generalSettings.HttpMinimumLogLevel);
|
||||
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = generalSettings.HttpMinimumLogLevel;
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateXmltvSettings(XmltvSettingsViewModel XmltvSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateXmltvSettingsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
: IRequestHandler<UpdateXmltvSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateXmltvSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.XmltvSettings);
|
||||
|
||||
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings)
|
||||
{
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync())
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber));
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,9 @@ namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GeneralSettingsViewModel
|
||||
{
|
||||
public LogEventLevel MinimumLogLevel { get; set; }
|
||||
public LogEventLevel DefaultMinimumLogLevel { get; set; }
|
||||
public LogEventLevel ScanningMinimumLogLevel { get; set; }
|
||||
public LogEventLevel SchedulingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel StreamingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel HttpMinimumLogLevel { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,12 +13,28 @@ public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, Gen
|
||||
|
||||
public async Task<GeneralSettingsViewModel> Handle(GetGeneralSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LogEventLevel> maybeLogLevel =
|
||||
Option<LogEventLevel> maybeDefaultLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
|
||||
|
||||
Option<LogEventLevel> maybeScanningLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScanning);
|
||||
|
||||
Option<LogEventLevel> maybeSchedulingLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScheduling);
|
||||
|
||||
Option<LogEventLevel> maybeStreamingLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelStreaming);
|
||||
|
||||
Option<LogEventLevel> maybeHttpLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelHttp);
|
||||
|
||||
return new GeneralSettingsViewModel
|
||||
{
|
||||
MinimumLogLevel = await maybeLogLevel.IfNoneAsync(LogEventLevel.Information)
|
||||
DefaultMinimumLogLevel = await maybeDefaultLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
ScanningMinimumLogLevel = await maybeScanningLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
SchedulingMinimumLogLevel = await maybeSchedulingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
StreamingMinimumLogLevel = await maybeStreamingLevel.IfNoneAsync(LogEventLevel.Information),
|
||||
HttpMinimumLogLevel = await maybeHttpLevel.IfNoneAsync(LogEventLevel.Information)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetXmltvSettings : IRequest<XmltvSettingsViewModel>;
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetXmltvSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetXmltvSettings, XmltvSettingsViewModel>
|
||||
{
|
||||
public async Task<XmltvSettingsViewModel> Handle(GetXmltvSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<XmltvTimeZone> maybeTimeZone =
|
||||
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone);
|
||||
|
||||
return new XmltvSettingsViewModel
|
||||
{
|
||||
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class XmltvSettingsViewModel
|
||||
{
|
||||
public XmltvTimeZone TimeZone { get; set; }
|
||||
}
|
||||
7
ErsatzTV.Application/Configuration/XmltvTimeZone.cs
Normal file
7
ErsatzTV.Application/Configuration/XmltvTimeZone.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public enum XmltvTimeZone
|
||||
{
|
||||
Local = 0,
|
||||
Utc = 1
|
||||
}
|
||||
@@ -92,7 +92,7 @@ public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLi
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate);
|
||||
if (ids.Any())
|
||||
if (ids.Count != 0)
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record UpdateEmbyLibraryPreferences
|
||||
(List<EmbyLibraryPreference> Preferences) : IRequest<Either<BaseError, Unit>>;
|
||||
public record UpdateEmbyLibraryPreferences(List<EmbyLibraryPreference> Preferences) : IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
public record EmbyLibraryPreference(int Id, bool ShouldSyncItems);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class
|
||||
UpdateEmbyLibraryPreferencesHandler : IRequestHandler<UpdateEmbyLibraryPreferences,
|
||||
Either<BaseError, Unit>>
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
@@ -4,9 +4,9 @@ using ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record EmbyLibraryViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
bool ShouldSyncItems,
|
||||
int MediaSourceId)
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
bool ShouldSyncItems,
|
||||
int MediaSourceId)
|
||||
: LibraryViewModel("Emby", Id, Name, MediaKind, MediaSourceId, string.Empty);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record GetEmbyPathReplacementsBySourceId
|
||||
(int EmbyMediaSourceId) : IRequest<List<EmbyPathReplacementViewModel>>;
|
||||
public record GetEmbyPathReplacementsBySourceId(int EmbyMediaSourceId) : IRequest<List<EmbyPathReplacementViewModel>>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
@@ -10,17 +10,19 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.4" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.6" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.7.30">
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.9.28">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="2.0.0" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="3.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=programschedules_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=resolutions_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=scheduling_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=scheduling_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public record CopyFFmpegProfile
|
||||
(int FFmpegProfileId, string Name) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
public record CopyFFmpegProfile(int FFmpegProfileId, string Name) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
@@ -8,9 +10,13 @@ public class
|
||||
CopyFFmpegProfileHandler : IRequestHandler<CopyFFmpegProfile, Either<BaseError, FFmpegProfileViewModel>>
|
||||
{
|
||||
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
|
||||
public CopyFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository) =>
|
||||
public CopyFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository, ISearchTargets searchTargets)
|
||||
{
|
||||
_ffmpegProfileRepository = ffmpegProfileRepository;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, FFmpegProfileViewModel>> Handle(
|
||||
CopyFFmpegProfile request,
|
||||
@@ -19,9 +25,12 @@ public class
|
||||
.MapT(PerformCopy)
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<FFmpegProfileViewModel> PerformCopy(CopyFFmpegProfile request) =>
|
||||
_ffmpegProfileRepository.Copy(request.FFmpegProfileId, request.Name)
|
||||
.Map(ProjectToViewModel);
|
||||
private async Task<FFmpegProfileViewModel> PerformCopy(CopyFFmpegProfile request)
|
||||
{
|
||||
FFmpegProfile copy = await _ffmpegProfileRepository.Copy(request.FFmpegProfileId, request.Name);
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
return ProjectToViewModel(copy);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, CopyFFmpegProfile>> Validate(CopyFFmpegProfile request) =>
|
||||
ValidateName(request).AsTask().MapT(_ => request);
|
||||
|
||||
@@ -12,6 +12,7 @@ public record CreateFFmpegProfile(
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
@@ -19,7 +20,7 @@ public record CreateFFmpegProfile(
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
bool NormalizeLoudness,
|
||||
NormalizeLoudnessMode NormalizeLoudnessMode,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -10,9 +11,13 @@ public class CreateFFmpegProfileHandler :
|
||||
IRequestHandler<CreateFFmpegProfile, Either<BaseError, CreateFFmpegProfileResult>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
|
||||
public CreateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
public CreateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, CreateFFmpegProfileResult>> Handle(
|
||||
CreateFFmpegProfile request,
|
||||
@@ -23,12 +28,13 @@ public class CreateFFmpegProfileHandler :
|
||||
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||
}
|
||||
|
||||
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
|
||||
private async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
|
||||
TvContext dbContext,
|
||||
FFmpegProfile ffmpegProfile)
|
||||
{
|
||||
await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile);
|
||||
await dbContext.SaveChangesAsync();
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
|
||||
}
|
||||
|
||||
@@ -46,6 +52,7 @@ public class CreateFFmpegProfileHandler :
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
VideoFormat = request.VideoFormat,
|
||||
BitDepth = request.BitDepth,
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
@@ -53,7 +60,7 @@ public class CreateFFmpegProfileHandler :
|
||||
AudioFormat = request.AudioFormat,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
NormalizeLoudness = request.NormalizeLoudness,
|
||||
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -9,23 +10,28 @@ namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
|
||||
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
}
|
||||
|
||||
private static async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
|
||||
{
|
||||
dbContext.FFmpegProfiles.Remove(ffmpegProfile);
|
||||
await dbContext.SaveChangesAsync();
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ public record UpdateFFmpegProfile(
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
@@ -20,7 +21,7 @@ public record UpdateFFmpegProfile(
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
bool NormalizeLoudness,
|
||||
NormalizeLoudnessMode NormalizeLoudnessMode,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -10,9 +11,13 @@ public class
|
||||
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
|
||||
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
|
||||
UpdateFFmpegProfile request,
|
||||
@@ -23,7 +28,7 @@ public class
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
|
||||
}
|
||||
|
||||
private static async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
|
||||
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
FFmpegProfile p,
|
||||
UpdateFFmpegProfile update)
|
||||
@@ -35,6 +40,7 @@ public class
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.ScalingBehavior = update.ScalingBehavior;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
@@ -47,12 +53,15 @@ public class
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
p.AudioBitrate = update.AudioBitrate;
|
||||
p.AudioBufferSize = update.AudioBufferSize;
|
||||
p.NormalizeLoudness = update.NormalizeLoudness;
|
||||
p.NormalizeLoudnessMode = update.NormalizeLoudnessMode;
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeFramerate = update.NormalizeFramerate;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
|
||||
return new UpdateFFmpegProfileResult(p.Id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -11,13 +13,16 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem)
|
||||
ILocalFileSystem localFileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
@@ -87,6 +92,26 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
ConfigElementKey.FFmpegPreferredLanguageCode,
|
||||
request.Settings.PreferredAudioLanguageCode);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
|
||||
request.Settings.UseEmbeddedSubtitles);
|
||||
|
||||
// do not extract when subtitles are not used
|
||||
if (request.Settings.UseEmbeddedSubtitles == false)
|
||||
{
|
||||
request.Settings.ExtractEmbeddedSubtitles = false;
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
|
||||
request.Settings.ExtractEmbeddedSubtitles);
|
||||
|
||||
// queue extracting all embedded subtitles
|
||||
if (request.Settings.ExtractEmbeddedSubtitles)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None));
|
||||
}
|
||||
|
||||
if (request.Settings.GlobalWatermarkId is not null)
|
||||
{
|
||||
await _configElementRepository.Upsert(
|
||||
|
||||
@@ -13,6 +13,7 @@ public record FFmpegProfileViewModel(
|
||||
string VaapiDevice,
|
||||
int? QsvExtraHardwareFrames,
|
||||
ResolutionViewModel Resolution,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
@@ -20,7 +21,7 @@ public record FFmpegProfileViewModel(
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
bool NormalizeLoudness,
|
||||
NormalizeLoudnessMode NormalizeLoudnessMode,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
|
||||
@@ -8,6 +8,8 @@ public class FFmpegSettingsViewModel
|
||||
public string FFprobePath { get; set; }
|
||||
public int DefaultFFmpegProfileId { get; set; }
|
||||
public string PreferredAudioLanguageCode { get; set; }
|
||||
public bool UseEmbeddedSubtitles { get; set; }
|
||||
public bool ExtractEmbeddedSubtitles { get; set; }
|
||||
public bool SaveReports { get; set; }
|
||||
public int? GlobalWatermarkId { get; set; }
|
||||
public int? GlobalFallbackFillerId { get; set; }
|
||||
|
||||
@@ -15,6 +15,7 @@ internal static class Mapper
|
||||
profile.VaapiDevice,
|
||||
profile.QsvExtraHardwareFrames,
|
||||
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
|
||||
profile.ScalingBehavior,
|
||||
profile.VideoFormat,
|
||||
profile.BitDepth,
|
||||
profile.VideoBitrate,
|
||||
@@ -22,7 +23,7 @@ internal static class Mapper
|
||||
profile.AudioFormat,
|
||||
profile.AudioBitrate,
|
||||
profile.AudioBufferSize,
|
||||
profile.NormalizeLoudness,
|
||||
profile.NormalizeLoudnessMode,
|
||||
profile.AudioChannels,
|
||||
profile.AudioSampleRate,
|
||||
profile.NormalizeFramerate,
|
||||
@@ -51,7 +52,7 @@ internal static class Mapper
|
||||
(int)ffmpegProfile.AudioFormat,
|
||||
ffmpegProfile.AudioBitrate,
|
||||
ffmpegProfile.AudioBufferSize,
|
||||
ffmpegProfile.NormalizeLoudness,
|
||||
(int)ffmpegProfile.NormalizeLoudnessMode,
|
||||
ffmpegProfile.AudioChannels,
|
||||
ffmpegProfile.AudioSampleRate,
|
||||
ffmpegProfile.NormalizeFramerate,
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class
|
||||
GetFFmpegProfileByIdForApiHandler : IRequestHandler<GetFFmpegFullProfileByIdForApi,
|
||||
Option<FFmpegFullProfileResponseModel>>
|
||||
Option<FFmpegFullProfileResponseModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
|
||||
Option<string> preferredAudioLanguageCode =
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
|
||||
Option<bool> useEmbeddedSubtitles =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles);
|
||||
Option<bool> extractEmbeddedSubtitles =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles);
|
||||
Option<int> watermark =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||
Option<int> fallbackFiller =
|
||||
@@ -42,6 +46,8 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
|
||||
FFprobePath = await ffprobePath.IfNoneAsync(string.Empty),
|
||||
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
|
||||
SaveReports = await saveReports.IfNoneAsync(false),
|
||||
UseEmbeddedSubtitles = await useEmbeddedSubtitles.IfNoneAsync(true),
|
||||
ExtractEmbeddedSubtitles = await extractEmbeddedSubtitles.IfNoneAsync(false),
|
||||
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
|
||||
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
|
||||
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public record GetSupportedHardwareAccelerationKinds : IRequest<List<HardwareAccelerationKind>>;
|
||||
@@ -0,0 +1,79 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class
|
||||
GetSupportedHardwareAccelerationKindsHandler : IRequestHandler<GetSupportedHardwareAccelerationKinds,
|
||||
List<HardwareAccelerationKind>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
|
||||
|
||||
public GetSupportedHardwareAccelerationKindsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
|
||||
}
|
||||
|
||||
public async Task<List<HardwareAccelerationKind>> Handle(
|
||||
GetSupportedHardwareAccelerationKinds request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, string> validation = await Validate(dbContext);
|
||||
|
||||
return await validation.Match(
|
||||
GetHardwareAccelerationKinds,
|
||||
_ => Task.FromResult(new List<HardwareAccelerationKind> { HardwareAccelerationKind.None }));
|
||||
}
|
||||
|
||||
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(string ffmpegPath)
|
||||
{
|
||||
var result = new List<HardwareAccelerationKind> { HardwareAccelerationKind.None };
|
||||
|
||||
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
|
||||
|
||||
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc))
|
||||
{
|
||||
result.Add(HardwareAccelerationKind.Nvenc);
|
||||
}
|
||||
|
||||
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Qsv))
|
||||
{
|
||||
result.Add(HardwareAccelerationKind.Qsv);
|
||||
}
|
||||
|
||||
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Vaapi))
|
||||
{
|
||||
result.Add(HardwareAccelerationKind.Vaapi);
|
||||
}
|
||||
|
||||
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.VideoToolbox))
|
||||
{
|
||||
result.Add(HardwareAccelerationKind.VideoToolbox);
|
||||
}
|
||||
|
||||
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Amf))
|
||||
{
|
||||
result.Add(HardwareAccelerationKind.Amf);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, string>> Validate(TvContext dbContext) =>
|
||||
await FFmpegPathMustExist(dbContext);
|
||||
|
||||
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext) =>
|
||||
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public record UpdateImageFolderDuration(int LibraryFolderId, double? ImageFolderDuration) : IRequest<double?>;
|
||||
@@ -0,0 +1,124 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public class UpdateImageFolderDurationHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<UpdateImageFolderDuration, double?>
|
||||
{
|
||||
public async Task<double?> Handle(UpdateImageFolderDuration request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (request.ImageFolderDuration.IfNone(1) < 0.01)
|
||||
{
|
||||
request = request with { ImageFolderDuration = 0.01 };
|
||||
}
|
||||
|
||||
// delete entry if null
|
||||
if (request.ImageFolderDuration is null)
|
||||
{
|
||||
await dbContext.ImageFolderDurations
|
||||
.Filter(ifd => ifd.LibraryFolderId == request.LibraryFolderId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
// upsert if non-null
|
||||
else
|
||||
{
|
||||
Option<ImageFolderDuration> maybeExisting = await dbContext.ImageFolderDurations
|
||||
.SelectOneAsync(ifd => ifd.LibraryFolderId, ifd => ifd.LibraryFolderId == request.LibraryFolderId);
|
||||
|
||||
if (maybeExisting.IsNone)
|
||||
{
|
||||
var entry = new ImageFolderDuration
|
||||
{
|
||||
LibraryFolderId = request.LibraryFolderId
|
||||
};
|
||||
|
||||
maybeExisting = entry;
|
||||
|
||||
await dbContext.ImageFolderDurations.AddAsync(entry, cancellationToken);
|
||||
}
|
||||
|
||||
foreach (ImageFolderDuration existing in maybeExisting)
|
||||
{
|
||||
existing.DurationSeconds = request.ImageFolderDuration.Value;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// update all images (bfs) starting at this folder
|
||||
Option<LibraryFolder> maybeFolder = await dbContext.LibraryFolders
|
||||
.AsNoTracking()
|
||||
.Include(lf => lf.ImageFolderDuration)
|
||||
.SelectOneAsync(lf => lf.Id, lf => lf.Id == request.LibraryFolderId);
|
||||
|
||||
var queue = new Queue<FolderWithParentDuration>();
|
||||
foreach (LibraryFolder libraryFolder in maybeFolder)
|
||||
{
|
||||
LibraryFolder currentFolder = libraryFolder;
|
||||
|
||||
// walk up to get duration, if needed
|
||||
double? durationSeconds = currentFolder.ImageFolderDuration?.DurationSeconds;
|
||||
while (durationSeconds is null && currentFolder?.ParentId is not null)
|
||||
{
|
||||
Option<LibraryFolder> maybeParent = await dbContext.LibraryFolders
|
||||
.AsNoTracking()
|
||||
.Include(lf => lf.ImageFolderDuration)
|
||||
.SelectOneAsync(lf => lf.Id, lf => lf.Id == currentFolder.ParentId);
|
||||
|
||||
if (maybeParent.IsNone)
|
||||
{
|
||||
currentFolder = null;
|
||||
}
|
||||
|
||||
foreach (LibraryFolder parent in maybeParent)
|
||||
{
|
||||
currentFolder = parent;
|
||||
durationSeconds = currentFolder.ImageFolderDuration?.DurationSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
queue.Enqueue(new FolderWithParentDuration(libraryFolder, durationSeconds));
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
(LibraryFolder currentFolder, double? parentDuration) = queue.Dequeue();
|
||||
double? effectiveDuration = currentFolder.ImageFolderDuration?.DurationSeconds ?? parentDuration;
|
||||
|
||||
// Serilog.Log.Logger.Information(
|
||||
// "Updating folder {Id} with parent duration {ParentDuration}, effective duration {EffectiveDuration}",
|
||||
// currentFolder.Id,
|
||||
// parentDuration,
|
||||
// effectiveDuration);
|
||||
|
||||
// update all images in this folder
|
||||
await dbContext.ImageMetadata
|
||||
.Filter(
|
||||
im => im.Image.MediaVersions.Any(
|
||||
mv => mv.MediaFiles.Any(mf => mf.LibraryFolderId == currentFolder.Id)))
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(im => im.DurationSeconds, effectiveDuration),
|
||||
cancellationToken);
|
||||
|
||||
List<LibraryFolder> children = await dbContext.LibraryFolders
|
||||
.AsNoTracking()
|
||||
.Filter(lf => lf.ParentId == currentFolder.Id)
|
||||
.Include(lf => lf.ImageFolderDuration)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// queue all children
|
||||
foreach (LibraryFolder child in children)
|
||||
{
|
||||
queue.Enqueue(new FolderWithParentDuration(child, effectiveDuration));
|
||||
}
|
||||
}
|
||||
|
||||
return request.ImageFolderDuration;
|
||||
}
|
||||
|
||||
private sealed record FolderWithParentDuration(LibraryFolder LibraryFolder, double? ParentDuration);
|
||||
}
|
||||
9
ErsatzTV.Application/Images/ImageFolderViewModel.cs
Normal file
9
ErsatzTV.Application/Images/ImageFolderViewModel.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public record ImageFolderViewModel(
|
||||
int LibraryFolderId,
|
||||
string Name,
|
||||
string FullPath,
|
||||
int SubfolderCount,
|
||||
int ImageCount,
|
||||
Option<double> DurationSeconds);
|
||||
18
ErsatzTV.Application/Images/Mapper.cs
Normal file
18
ErsatzTV.Application/Images/Mapper.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public static class Mapper
|
||||
{
|
||||
public static ImageFolderViewModel ProjectToViewModel(
|
||||
LibraryFolder libraryFolder,
|
||||
int childCount,
|
||||
int imageCount) =>
|
||||
new(
|
||||
libraryFolder.Id,
|
||||
new DirectoryInfo(libraryFolder.Path).Name,
|
||||
libraryFolder.Path,
|
||||
childCount,
|
||||
imageCount,
|
||||
libraryFolder.ImageFolderDuration?.DurationSeconds ?? Option<double>.None);
|
||||
}
|
||||
@@ -3,6 +3,5 @@ using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public record GetCachedImagePath
|
||||
(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<
|
||||
Either<BaseError, CachedImagePathViewModel>>;
|
||||
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<
|
||||
Either<BaseError, CachedImagePathViewModel>>;
|
||||
|
||||
3
ErsatzTV.Application/Images/Queries/GetImageFolders.cs
Normal file
3
ErsatzTV.Application/Images/Queries/GetImageFolders.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public record GetImageFolders(Option<int> LibraryFolderId) : IRequest<List<ImageFolderViewModel>>;
|
||||
@@ -0,0 +1,49 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public class GetImageFoldersHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetImageFolders, List<ImageFolderViewModel>>
|
||||
{
|
||||
public async Task<List<ImageFolderViewModel>> Handle(GetImageFolders request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// default to returning top-level folders
|
||||
int? parentId = null;
|
||||
|
||||
// if a specific folder is requested, return its children
|
||||
foreach (int libraryFolderId in request.LibraryFolderId)
|
||||
{
|
||||
parentId = libraryFolderId;
|
||||
}
|
||||
|
||||
List<LibraryFolder> folders = await dbContext.LibraryFolders
|
||||
.AsNoTracking()
|
||||
.Include(lf => lf.ImageFolderDuration)
|
||||
.Filter(lf => lf.LibraryPath.Library.MediaKind == LibraryMediaKind.Images)
|
||||
.Filter(lf => lf.ParentId == parentId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var result = new List<ImageFolderViewModel>();
|
||||
|
||||
foreach (LibraryFolder folder in folders)
|
||||
{
|
||||
// count direct children of this folder
|
||||
int childCount = await dbContext.LibraryFolders
|
||||
.AsNoTracking()
|
||||
.CountAsync(lf => lf.ParentId == folder.Id, cancellationToken);
|
||||
|
||||
// count all child images (any level)
|
||||
int imageCount = await dbContext.MediaFiles
|
||||
.AsNoTracking()
|
||||
.CountAsync(mf => mf.Path.StartsWith(folder.Path), cancellationToken);
|
||||
|
||||
result.Add(Mapper.ProjectToViewModel(folder, childCount, imageCount));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinCollections>,
|
||||
IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
public CallJellyfinCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeJellyfinCollections request)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.JellyfinMediaSources
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
SynchronizeJellyfinCollections request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
SynchronizeJellyfinCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-jellyfin-collections", request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class
|
||||
SynchronizeJellyfinAdminUserIdHandler : IRequestHandler<SynchronizeJellyfinAdminUserId,
|
||||
Either<BaseError, Unit>>
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) :
|
||||
IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
@@ -94,7 +94,7 @@ public class
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate);
|
||||
if (ids.Any())
|
||||
if (ids.Count != 0)
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -16,8 +16,8 @@ public record SynchronizeJellyfinLibraryByIdIfNeeded(int JellyfinLibraryId) : IS
|
||||
public bool DeepScan => false;
|
||||
}
|
||||
|
||||
public record ForceSynchronizeJellyfinLibraryById
|
||||
(int JellyfinLibraryId, bool DeepScan) : ISynchronizeJellyfinLibraryById
|
||||
public record ForceSynchronizeJellyfinLibraryById(int JellyfinLibraryId, bool DeepScan)
|
||||
: ISynchronizeJellyfinLibraryById
|
||||
{
|
||||
public bool ForceScan => true;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record UpdateJellyfinLibraryPreferences
|
||||
(List<JellyfinLibraryPreference> Preferences) : IRequest<Either<BaseError, Unit>>;
|
||||
public record UpdateJellyfinLibraryPreferences(List<JellyfinLibraryPreference> Preferences)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
public record JellyfinLibraryPreference(int Id, bool ShouldSyncItems);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class
|
||||
UpdateJellyfinLibraryPreferencesHandler : IRequestHandler<UpdateJellyfinLibraryPreferences,
|
||||
Either<BaseError, Unit>>
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
@@ -4,9 +4,9 @@ using ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record JellyfinLibraryViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
bool ShouldSyncItems,
|
||||
int MediaSourceId)
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
bool ShouldSyncItems,
|
||||
int MediaSourceId)
|
||||
: LibraryViewModel("Jellyfin", Id, Name, MediaKind, MediaSourceId, string.Empty);
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class
|
||||
GetAllJellyfinMediaSourcesHandler : IRequestHandler<GetAllJellyfinMediaSources,
|
||||
List<JellyfinMediaSourceViewModel>>
|
||||
List<JellyfinMediaSourceViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class
|
||||
GetJellyfinLibrariesBySourceIdHandler : IRequestHandler<GetJellyfinLibrariesBySourceId,
|
||||
List<JellyfinLibraryViewModel>>
|
||||
List<JellyfinLibraryViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record GetJellyfinMediaSourceById
|
||||
(int JellyfinMediaSourceId) : IRequest<Option<JellyfinMediaSourceViewModel>>;
|
||||
public record GetJellyfinMediaSourceById(int JellyfinMediaSourceId) : IRequest<Option<JellyfinMediaSourceViewModel>>;
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class
|
||||
GetJellyfinMediaSourceByIdHandler : IRequestHandler<GetJellyfinMediaSourceById,
|
||||
Option<JellyfinMediaSourceViewModel>>
|
||||
Option<JellyfinMediaSourceViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record GetJellyfinPathReplacementsBySourceId
|
||||
(int JellyfinMediaSourceId) : IRequest<List<JellyfinPathReplacementViewModel>>;
|
||||
public record GetJellyfinPathReplacementsBySourceId(int JellyfinMediaSourceId)
|
||||
: IRequest<List<JellyfinPathReplacementViewModel>>;
|
||||
|
||||
@@ -13,6 +13,7 @@ using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Compact.Reader;
|
||||
|
||||
@@ -20,11 +21,14 @@ namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public abstract class CallLibraryScannerHandler<TRequest>
|
||||
{
|
||||
private readonly int _batchSize = 100;
|
||||
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private readonly List<int> _toReindex = [];
|
||||
private readonly List<int> _toRemove = [];
|
||||
private string _libraryName;
|
||||
|
||||
protected CallLibraryScannerHandler(
|
||||
@@ -65,6 +69,18 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
{
|
||||
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
|
||||
}
|
||||
|
||||
if (_toReindex.Count > 0)
|
||||
{
|
||||
await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray()), cancellationToken);
|
||||
_toReindex.Clear();
|
||||
}
|
||||
|
||||
if (_toRemove.Count > 0)
|
||||
{
|
||||
await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray()), cancellationToken);
|
||||
_toRemove.Clear();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
@@ -84,7 +100,16 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
// because the compact json writer used by the scanner
|
||||
// writes in UTC
|
||||
LogEvent logEvent = LogEventReader.ReadFromString(s);
|
||||
Log.Write(
|
||||
|
||||
ILogger log = Log.Logger;
|
||||
if (logEvent.Properties.TryGetValue("SourceContext", out LogEventPropertyValue property))
|
||||
{
|
||||
log = log.ForContext(
|
||||
Constants.SourceContextPropertyName,
|
||||
property.ToString().Trim('"'));
|
||||
}
|
||||
|
||||
log.Write(
|
||||
new LogEvent(
|
||||
logEvent.Timestamp.ToLocalTime(),
|
||||
logEvent.Level,
|
||||
@@ -113,6 +138,20 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
_libraryName = progressUpdate.LibraryName;
|
||||
}
|
||||
|
||||
_toReindex.AddRange(progressUpdate.ItemsToReindex);
|
||||
if (_toReindex.Count >= _batchSize)
|
||||
{
|
||||
await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray()));
|
||||
_toReindex.Clear();
|
||||
}
|
||||
|
||||
_toRemove.AddRange(progressUpdate.ItemsToRemove);
|
||||
if (_toRemove.Count >= _batchSize)
|
||||
{
|
||||
await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray()));
|
||||
_toRemove.Clear();
|
||||
}
|
||||
|
||||
if (progressUpdate.PercentComplete is not null)
|
||||
{
|
||||
var progress = new LibraryScanProgress(
|
||||
@@ -121,18 +160,6 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
|
||||
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)
|
||||
@@ -166,9 +193,16 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
: "ErsatzTV.Scanner";
|
||||
|
||||
string processFileName = Environment.ProcessPath ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(processFileName))
|
||||
string processExecutable = Path.GetFileNameWithoutExtension(processFileName);
|
||||
string folderName = Path.GetDirectoryName(processFileName);
|
||||
if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string localFileName = Path.Combine(Path.GetDirectoryName(processFileName) ?? string.Empty, executable);
|
||||
folderName = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(folderName))
|
||||
{
|
||||
string localFileName = Path.Combine(folderName, executable);
|
||||
if (File.Exists(localFileName))
|
||||
{
|
||||
return localFileName;
|
||||
|
||||
@@ -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 LanguageExtensions.Apply(validation, localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record CreateLocalLibraryPath
|
||||
(int LibraryId, string Path) : IRequest<Either<BaseError, LocalLibraryPathViewModel>>;
|
||||
public record CreateLocalLibraryPath(int LibraryId, string Path)
|
||||
: IRequest<Either<BaseError, LocalLibraryPathViewModel>>;
|
||||
|
||||
@@ -14,9 +14,7 @@ public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public DeleteLocalLibraryHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchIndex searchIndex)
|
||||
public DeleteLocalLibraryHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchIndex searchIndex)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_searchIndex = searchIndex;
|
||||
@@ -28,21 +26,46 @@ public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await LocalLibraryMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, localLibrary => DoDeletion(dbContext, localLibrary));
|
||||
return await validation.Apply(localLibrary => DoDeletion(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, LocalLibrary localLibrary)
|
||||
{
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT MediaItem.Id FROM MediaItem
|
||||
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
"""
|
||||
SELECT MediaItem.Id FROM MediaItem
|
||||
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId
|
||||
""",
|
||||
new { LibraryId = localLibrary.Id })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM MediaItem WHERE Id IN
|
||||
(
|
||||
SELECT MI.Id FROM MediaItem MI
|
||||
INNER JOIN LibraryPath LP ON MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId
|
||||
)
|
||||
""",
|
||||
new { LibraryId = localLibrary.Id });
|
||||
|
||||
// delete all library folders (children first)
|
||||
IOrderedQueryable<LibraryFolder> orderedFolders = dbContext.LibraryFolders
|
||||
.Filter(lf => lf.LibraryPath.LibraryId == localLibrary.Id)
|
||||
.OrderByDescending(lp => lp.Path.Length);
|
||||
|
||||
foreach (LibraryFolder folder in orderedFolders)
|
||||
{
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"DELETE FROM LibraryFolder WHERE Id = @LibraryFolderId",
|
||||
new { LibraryFolderId = folder.Id });
|
||||
}
|
||||
|
||||
dbContext.LocalLibraries.Remove(localLibrary);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, parameters => MovePath(dbContext, parameters));
|
||||
return await validation.Apply(parameters => MovePath(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -37,7 +38,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<LocalLibraryViewModel> UpdateLocalLibrary(TvContext dbContext, Parameters parameters)
|
||||
@@ -54,16 +55,36 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
var toRemoveIds = toRemove.Map(lp => lp.Id).ToList();
|
||||
|
||||
List<int> itemsToRemove = await dbContext.MediaItems
|
||||
.Filter(mi => toRemoveIds.Contains(mi.LibraryPathId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"DELETE FROM MediaItem WHERE LibraryPathId IN @Ids",
|
||||
new { Ids = toRemoveIds });
|
||||
|
||||
// delete all library folders (children first)
|
||||
IOrderedQueryable<LibraryFolder> orderedFolders = dbContext.LibraryFolders
|
||||
.Filter(lf => toRemoveIds.Contains(lf.LibraryPathId))
|
||||
.OrderByDescending(lp => lp.Path.Length);
|
||||
|
||||
foreach (LibraryFolder folder in orderedFolders)
|
||||
{
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"DELETE FROM LibraryFolder WHERE Id = @LibraryFolderId",
|
||||
new { LibraryFolderId = folder.Id });
|
||||
}
|
||||
|
||||
await dbContext.LibraryPaths
|
||||
.Filter(lp => toRemoveIds.Contains(lp.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
existing.Paths.RemoveAll(toRemove.Contains);
|
||||
existing.Paths.AddRange(toAdd);
|
||||
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
List<int> itemsToRemove = await dbContext.MediaItems
|
||||
.AsNoTracking()
|
||||
.Filter(mi => toRemoveIds.Contains(mi.LibraryPathId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
|
||||
await _searchIndex.RemoveItems(itemsToRemove);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record PlexLibraryViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
int MediaSourceId,
|
||||
string MediaSourceName)
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
int MediaSourceId,
|
||||
string MediaSourceName)
|
||||
: LibraryViewModel("Plex", Id, Name, MediaKind, MediaSourceId, MediaSourceName);
|
||||
|
||||
@@ -15,20 +15,51 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti
|
||||
GetExternalCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<LibraryViewModel> result = new();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<int> mediaSourceIds = await dbContext.EmbyMediaSources
|
||||
result.AddRange(await GetEmbyExternalCollections(dbContext, cancellationToken));
|
||||
result.AddRange(await GetJellyfinExternalCollections(dbContext, cancellationToken));
|
||||
result.AddRange(await GetPlexExternalCollections(dbContext, cancellationToken));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<LibraryViewModel>> GetEmbyExternalCollections(
|
||||
TvContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> embyMediaSourceIds = await dbContext.EmbyMediaSources
|
||||
.Filter(ems => ems.Libraries.Any(l => ((EmbyLibrary)l).ShouldSyncItems))
|
||||
.Map(ems => ems.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return mediaSourceIds.Map(
|
||||
id => new LibraryViewModel(
|
||||
"Emby",
|
||||
0,
|
||||
"Collections",
|
||||
0,
|
||||
id,
|
||||
string.Empty))
|
||||
.ToList();
|
||||
return embyMediaSourceIds.Map(id => new LibraryViewModel("Emby", 0, "Collections", 0, id, string.Empty));
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<LibraryViewModel>> GetJellyfinExternalCollections(
|
||||
TvContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> jellyfinMediaSourceIds = await dbContext.JellyfinMediaSources
|
||||
.Filter(jms => jms.Libraries.Any(l => ((JellyfinLibrary)l).ShouldSyncItems))
|
||||
.Map(jms => jms.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return jellyfinMediaSourceIds.Map(
|
||||
id => new LibraryViewModel("Jellyfin", 0, "Collections", 0, id, string.Empty));
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<LibraryViewModel>> GetPlexExternalCollections(
|
||||
TvContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> plexMediaSourceIds = await dbContext.PlexMediaSources
|
||||
.Filter(pms => pms.Libraries.Any(l => ((PlexLibrary)l).ShouldSyncItems))
|
||||
.Map(pms => pms.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return plexMediaSourceIds.Map(
|
||||
id => new LibraryViewModel("Plex", 0, "Collections", 0, id, string.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.SessionWorkers.Any() || FFmpegProcess.ProcessCount > 0;
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count >= 0 || FFmpegProcess.ProcessCount > 0;
|
||||
if (request.ForceAggressive || !hasActiveWorkers)
|
||||
{
|
||||
_logger.LogDebug("Starting aggressive garbage collection");
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record ArtistCardViewModel
|
||||
(
|
||||
public record ArtistCardViewModel(
|
||||
int ArtistId,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
|
||||
@@ -9,7 +9,8 @@ public record CollectionCardResultsViewModel(
|
||||
List<ArtistCardViewModel> ArtistCards,
|
||||
List<MusicVideoCardViewModel> MusicVideoCards,
|
||||
List<OtherVideoCardViewModel> OtherVideoCards,
|
||||
List<SongCardViewModel> SongCards)
|
||||
List<SongCardViewModel> SongCards,
|
||||
List<ImageCardViewModel> ImageCards)
|
||||
{
|
||||
public bool UseCustomPlaybackOrder { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Search;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record ImageCardResultsViewModel(int Count, List<ImageCardViewModel> Cards, SearchPageMap PageMap);
|
||||
20
ErsatzTV.Application/MediaCards/ImageCardViewModel.cs
Normal file
20
ErsatzTV.Application/MediaCards/ImageCardViewModel.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record ImageCardViewModel(
|
||||
int ImageId,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
string SortTitle,
|
||||
string Poster,
|
||||
MediaItemState State) : MediaCardViewModel(
|
||||
ImageId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
Poster,
|
||||
State)
|
||||
{
|
||||
public int CustomIndex { get; set; }
|
||||
}
|
||||
@@ -131,12 +131,21 @@ internal static class Mapper
|
||||
return new SongCardViewModel(
|
||||
songMetadata.SongId,
|
||||
songMetadata.Title,
|
||||
songMetadata.Artist + album,
|
||||
string.Join(", ", songMetadata.Artists) + album,
|
||||
songMetadata.SortTitle,
|
||||
GetThumbnail(songMetadata, None, None),
|
||||
songMetadata.Song.State);
|
||||
}
|
||||
|
||||
internal static ImageCardViewModel ProjectToViewModel(ImageMetadata imageMetadata) =>
|
||||
new(
|
||||
imageMetadata.ImageId,
|
||||
imageMetadata.Title,
|
||||
imageMetadata.OriginalTitle,
|
||||
imageMetadata.SortTitle,
|
||||
string.Empty, // TODO: thumbnail?
|
||||
imageMetadata.Image.State);
|
||||
|
||||
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
|
||||
new(
|
||||
artistMetadata.ArtistId,
|
||||
@@ -152,30 +161,38 @@ internal static class Mapper
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
Option<EmbyMediaSource> maybeEmby) =>
|
||||
new(
|
||||
collection.Name,
|
||||
collection.MediaItems.OfType<Movie>().Map(
|
||||
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
|
||||
{
|
||||
CustomIndex = GetCustomIndex(collection, m.Id)
|
||||
}).ToList(),
|
||||
collection.MediaItems.OfType<Show>()
|
||||
.Map(s => ProjectToViewModel(s.ShowMetadata.Head(), maybeJellyfin, maybeEmby))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby))
|
||||
.ToList(),
|
||||
// collection view doesn't use local paths
|
||||
collection.MediaItems.OfType<Episode>()
|
||||
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false, string.Empty))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
|
||||
// collection view doesn't use local paths
|
||||
collection.MediaItems.OfType<MusicVideo>()
|
||||
.Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head(), string.Empty))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<OtherVideo>().Map(ov => ProjectToViewModel(ov.OtherVideoMetadata.Head()))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Song>().Map(s => ProjectToViewModel(s.SongMetadata.Head()))
|
||||
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
|
||||
collection.Name,
|
||||
collection.MediaItems.OfType<Movie>().Map(
|
||||
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
|
||||
{
|
||||
CustomIndex = GetCustomIndex(collection, m.Id)
|
||||
}).ToList(),
|
||||
collection.MediaItems.OfType<Show>()
|
||||
.Map(s => ProjectToViewModel(s.ShowMetadata.Head(), maybeJellyfin, maybeEmby))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby))
|
||||
.ToList(),
|
||||
// collection view doesn't use local paths
|
||||
collection.MediaItems.OfType<Episode>()
|
||||
.Map(
|
||||
e => ProjectToViewModel(
|
||||
e.EpisodeMetadata.Head(),
|
||||
maybeJellyfin,
|
||||
maybeEmby,
|
||||
false,
|
||||
string.Empty))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
|
||||
// collection view doesn't use local paths
|
||||
collection.MediaItems.OfType<MusicVideo>()
|
||||
.Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head(), string.Empty))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<OtherVideo>().Map(ov => ProjectToViewModel(ov.OtherVideoMetadata.Head()))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Song>().Map(s => ProjectToViewModel(s.SongMetadata.Head()))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Image>().Map(i => ProjectToViewModel(i.ImageMetadata.Head())).ToList())
|
||||
{ UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
|
||||
|
||||
internal static ActorCardViewModel ProjectToViewModel(
|
||||
Actor actor,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record MovieCardViewModel
|
||||
(
|
||||
public record MovieCardViewModel(
|
||||
int MovieId,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record MusicVideoCardViewModel
|
||||
(
|
||||
public record MusicVideoCardViewModel(
|
||||
int MusicVideoId,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record OtherVideoCardViewModel
|
||||
(
|
||||
public record OtherVideoCardViewModel(
|
||||
int OtherVideoId,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record GetMusicVideoCards
|
||||
(int ArtistId, int PageNumber, int PageSize) : IRequest<MusicVideoCardResultsViewModel>;
|
||||
public record GetMusicVideoCards(int ArtistId, int PageNumber, int PageSize) : IRequest<MusicVideoCardResultsViewModel>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record GetTelevisionEpisodeCards
|
||||
(int TelevisionSeasonId, int PageNumber, int PageSize) : IRequest<TelevisionEpisodeCardResultsViewModel>;
|
||||
public record GetTelevisionEpisodeCards(int TelevisionSeasonId, int PageNumber, int PageSize)
|
||||
: IRequest<TelevisionEpisodeCardResultsViewModel>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record GetTelevisionSeasonCards
|
||||
(int TelevisionShowId, int PageNumber, int PageSize) : IRequest<TelevisionSeasonCardResultsViewModel>;
|
||||
public record GetTelevisionSeasonCards(int TelevisionShowId, int PageNumber, int PageSize)
|
||||
: IRequest<TelevisionSeasonCardResultsViewModel>;
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public class
|
||||
GetTelevisionSeasonCardsHandler : IRequestHandler<GetTelevisionSeasonCards,
|
||||
TelevisionSeasonCardResultsViewModel>
|
||||
TelevisionSeasonCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record SongCardViewModel
|
||||
(
|
||||
public record SongCardViewModel(
|
||||
int SongId,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public record TelevisionEpisodeCardViewModel
|
||||
(
|
||||
public record TelevisionEpisodeCardViewModel(
|
||||
int EpisodeId,
|
||||
DateTime Aired,
|
||||
string ShowTitle,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user