Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f101d0b366 | ||
|
|
73aabdabda | ||
|
|
bcea96d53a | ||
|
|
d7952e4cfa | ||
|
|
758399e339 | ||
|
|
6c635a4be9 | ||
|
|
9d637cdd54 | ||
|
|
cc287ffc6e | ||
|
|
371659c5c5 | ||
|
|
7afb1866ad | ||
|
|
7bc1dd63fe | ||
|
|
076b8a7188 | ||
|
|
ec0d8ea6ac | ||
|
|
e40d192aea | ||
|
|
bd7fd8984c | ||
|
|
2682912f5a | ||
|
|
505e135482 | ||
|
|
fdf1e70e0d | ||
|
|
5c51710e2f | ||
|
|
3cb84c2491 | ||
|
|
21f4439aa4 | ||
|
|
d88e721d2f | ||
|
|
b6d509b9cd | ||
|
|
6603500132 | ||
|
|
48b1aa3e64 | ||
|
|
42b35f7aae | ||
|
|
8b18f2a304 | ||
|
|
1e0bba0dc6 | ||
|
|
3984bc7dbe | ||
|
|
e0977fa65b | ||
|
|
d9c668c7f6 | ||
|
|
dcea6d474f | ||
|
|
ba37c6dabe | ||
|
|
d652372f78 | ||
|
|
e2d8dee8cd | ||
|
|
d93c404607 | ||
|
|
bc400de94c | ||
|
|
b9a73226a8 | ||
|
|
d0505cd5c5 | ||
|
|
d9cdbc72de | ||
|
|
2b0079fedc | ||
|
|
9d2cff53c5 | ||
|
|
ac361b3165 | ||
|
|
dd9317e3e8 | ||
|
|
7530c592ff | ||
|
|
132466b3d3 | ||
|
|
d709cc9f21 | ||
|
|
5083e748ed | ||
|
|
053b3cd1d7 | ||
|
|
c3c7ff2669 | ||
|
|
e6824cf251 | ||
|
|
d87561d140 | ||
|
|
f79fa9a50a | ||
|
|
629b3d7d9f | ||
|
|
453737a521 | ||
|
|
dd38ba19ea | ||
|
|
8e2a15296f | ||
|
|
d2cbfcb79a | ||
|
|
89133255d3 | ||
|
|
c6245bae0c | ||
|
|
2912e71c10 | ||
|
|
9e54d42e5f | ||
|
|
63f342e6a7 | ||
|
|
5a7c59d602 | ||
|
|
4822ba5486 | ||
|
|
b24617fe7c | ||
|
|
5045a411b1 | ||
|
|
425fb34317 | ||
|
|
e5ef9be09c | ||
|
|
e9338b534b | ||
|
|
191e694545 | ||
|
|
727a978689 | ||
|
|
7a133d46da | ||
|
|
b1fbf651a2 | ||
|
|
0dbdcc3674 | ||
|
|
9eb7bbf0e6 | ||
|
|
e851a295a6 | ||
|
|
3b254735e6 | ||
|
|
1f8834c280 | ||
|
|
545db4db9b | ||
|
|
e590298b93 | ||
|
|
a47510fef3 | ||
|
|
e089b12c2b | ||
|
|
82e0fcaec8 | ||
|
|
f7c699248c | ||
|
|
2d53063ce9 | ||
|
|
626048f9c3 | ||
|
|
2ef2b0299a | ||
|
|
fcce53a3df | ||
|
|
d4353f6d42 | ||
|
|
64ea413b6f | ||
|
|
d14ebf3522 | ||
|
|
889904e70d | ||
|
|
35e7922836 | ||
|
|
ffe15629cb | ||
|
|
ba5a027525 | ||
|
|
a33ac4a048 | ||
|
|
7ae028e2e9 | ||
|
|
6404dee646 | ||
|
|
940d26419c | ||
|
|
9bae8e73bf | ||
|
|
f41f4b19d4 | ||
|
|
917acf9683 | ||
|
|
da4687ac0f | ||
|
|
d1af6599f0 | ||
|
|
4e43817f8e | ||
|
|
ebcd9a35a7 | ||
|
|
ea5956a268 | ||
|
|
65ff1f5502 | ||
|
|
5ef8b04119 | ||
|
|
99837e808a | ||
|
|
7c2083d3f2 | ||
|
|
b851a7daba | ||
|
|
48e7c85f7b | ||
|
|
bf4182f115 | ||
|
|
fb9ca8953e | ||
|
|
48310e044b | ||
|
|
144b3fe80b | ||
|
|
4a754c4e6a | ||
|
|
7059669023 | ||
|
|
c03f81a465 | ||
|
|
e3d07050bf | ||
|
|
5a88bfc310 | ||
|
|
dd92a65742 | ||
|
|
07ffa1642b | ||
|
|
6fc602323f | ||
|
|
d5fd8e7be6 | ||
|
|
dba5485300 | ||
|
|
6847a133ca | ||
|
|
fd60c120ae | ||
|
|
371d1d89fb | ||
|
|
ec6bc797f4 | ||
|
|
4c57167864 | ||
|
|
9016523757 | ||
|
|
6a38c91d54 | ||
|
|
9ec4d0a85c | ||
|
|
4cb98242ba | ||
|
|
0e2084838a | ||
|
|
f3f900b4ca | ||
|
|
c39858b2d8 | ||
|
|
0363609923 | ||
|
|
a02aa37957 |
@@ -3,11 +3,11 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.2.2.1",
|
||||
"version": "2025.3.0.2",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
.github/workflows/artifacts.yml
vendored
24
.github/workflows/artifacts.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -72,8 +72,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -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 net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -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 net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -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 net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Bundle
|
||||
shell: bash
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -180,8 +180,8 @@ jobs:
|
||||
|
||||
# Build everything
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -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 net9.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -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 net10.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -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 net10.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
mkdir "$release_name"
|
||||
mv scanner/* "$release_name/"
|
||||
mv main/* "$release_name/"
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -232,8 +232,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Upload .NET Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -292,7 +292,7 @@ jobs:
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-win-x64"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
mkdir "$release_name"
|
||||
|
||||
|
||||
mv dotnet-build/scanner/* "$release_name/"
|
||||
mv dotnet-build/main/* "$release_name/"
|
||||
|
||||
@@ -302,8 +302,8 @@ jobs:
|
||||
mv rust-build/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
|
||||
(cd "${release_name}" && zip -r "../${release_name}.zip" .)
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
|
||||
6
.github/workflows/pr.yml
vendored
6
.github/workflows/pr.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
192
CHANGELOG.md
192
CHANGELOG.md
@@ -5,7 +5,192 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [25.7.0] - 2025-09-14
|
||||
## [25.9.0] - 2025-11-29
|
||||
### Added
|
||||
- Show playout warnings count badge in left menu
|
||||
- Graphics Engine:
|
||||
- Add `MediaItem_Resolution` template data (the current `Resolution` variable is the FFmpeg Profile resolution)
|
||||
- Add `MediaItem_Start` template data (DateTimeOffset)
|
||||
- Add `MediaItem_Stop` template data (DateTimeOffset)
|
||||
- Add `ScaledResolution` template data (the final size of the frame before padding)
|
||||
- Add `place_within_source_content` (true/false) field to image graphics element
|
||||
- Add `name` field to all graphics elements to display in the UI
|
||||
- Classic and block schedules: add collection type `Search Query`
|
||||
- This allows defining search queries directly on schedule items without creating smart collections beforehand
|
||||
- As an example, this can be used to filter or combine existing smart collections
|
||||
- Filter: `smart_collection:"sd movies" AND plot:"christmas"`
|
||||
- Combine: `smart_collection:"old commercials" OR smart_collection:"nick promos"`
|
||||
- Scripted schedules: add `custom_title` to `start_epg_group`
|
||||
- Add MPEG-TS Script system
|
||||
- This allows using something other than ffmpeg (e.g. streamlink) to concatenate segments back together when using MPEG-TS streaming mode
|
||||
- Scripts live in config / scripts / mpegts
|
||||
- Each script gets its own subfolder which contains an `mpegts.yml` definition and corresponding windows (batch) and linux (bash) scripts
|
||||
- The global MPEG-TS script can be configured in **Settings** > **FFmpeg** > **Default MPEG-TS Script**
|
||||
- Add `.avs` AviSynth Script support to all local libraries
|
||||
- `.avs` was added as a valid extension, so they should behave the same any other video file
|
||||
- There are two requirements for AviSynth Scripts to work:
|
||||
- FFmpeg needs to be compiled with AviSynth support (not currently available in Docker)
|
||||
- AviSynth itself needs to be installed
|
||||
- Add `Troubleshoot` button to classic schedule list
|
||||
- This generates JSON representing the entire schedule which can be shared when requested for troubleshooting
|
||||
- Add **Settings** > **FFmpeg** > **Probe For Interlaced Frames**
|
||||
- When enabled, this will probe *local content* for interlaced frames on demand (immediately before playback)
|
||||
- This will be used as a more accurate check for interlaced content
|
||||
- The result will be cached (only probed once and stored) in the database along with all other media item statistics (e.g. duration)
|
||||
- This feature will currently ignore content that is not streamed from disk
|
||||
- Add error/offline background customization
|
||||
- Default error background is now named `_background.png`
|
||||
- Error streams will prioritize using `background.png` if it exists
|
||||
- Replacing this `background.png` file will allow custom error/offline backgrounds
|
||||
- Add `Troubleshoot Playback` buttons on movie and episode detail pages
|
||||
- Add song background and missing album art customization
|
||||
- Default files start with an underscore; custom versions must remove the underscore
|
||||
- Expose arbitrary EPG data to graphics engine via channel guide templates
|
||||
- XML nodes using the `etv:` namespace will be passed to the graphics engine EPG template data
|
||||
- For example, adding `<etv:episode_number_key>{{ episode_number }}</etv:episode_number_key>` to `episode.sbntxt` will also add the `episode_number_key` field to all EPG items in the graphics engine
|
||||
- All values parsed from XMLTV will be available as strings in the graphics engine (not numbers)
|
||||
- All `etv:` nodes will be stripped from the XMLTV data when requested by a client
|
||||
- Add channel troubleshooting button to channels list
|
||||
- This will open the playback troubleshooting tool in "channel" mode
|
||||
- This mode requires entering a date and time, and will play up to 30 seconds of *one item from that channel's playout* starting at the entered date and time
|
||||
- Block schedules: add copy template button to templates table
|
||||
|
||||
### Fixed
|
||||
- Fix HLS Direct playback with Jellyfin 10.11
|
||||
- Fix remote stream scripts (parsing issue with spaces and quotes)
|
||||
- Fix block history being removed when it is still needed for mirror channel
|
||||
- This caused playout build errors like "Unable to locate history for playout item"
|
||||
- Fix crashes due to invalid smart collection searches, e.g. `smart_collection:"this collection does not exist"`
|
||||
- Fix UI crash when editing block playout that has default deco
|
||||
- Fix playback failure when seeking content with certain DTS audio (e.g. DTS-HD MA)
|
||||
- Properly set explicit audio decoder on combined audio and video input file
|
||||
- Fix building sequential schedules across a UTC offset change
|
||||
- Fix block start time calculation across a UTC offset change
|
||||
- Fix classic schedule start time calculation across a UTC offset change
|
||||
- Fix XMLTV generation for channels using on-demand playout mode
|
||||
- Fix some file not found songs missing from trash view
|
||||
- Fix error/offline screen generation
|
||||
- Fix subtitle title sync from Jellyfin libraries
|
||||
- Deep scans will be required to update subtitle titles on existing media items
|
||||
- Fix saving subtitle title changes to the database
|
||||
- This fixes e.g. where stream selection would continue to use the original title
|
||||
- This fix applies to all libraries (local and media server)
|
||||
- Fix (3 year old) bug removing tags from local libraries when they are removed from NFO files (all content types)
|
||||
- New scans will properly remove old tags; NFO files may need to be touched to force updating during a scan
|
||||
- Fix bug where looping motion graphics wouldn't be displayed when seeking into second half of content
|
||||
- Fix `content_total_duration` value in graphics engine opacity expressions
|
||||
- This bug caused some graphics elements to display too early after first joining a channel
|
||||
- Optimize database calls made for search index rebuilds and updates
|
||||
- This should improve performance of library scans
|
||||
- Add toggle to hide/show disabled channels in channel list
|
||||
- Add disabled text color and `(D)` and `(H)` labels for disabled and hidden channels in channel list
|
||||
- Graphics engine: fix subtitle path escaping and font loading
|
||||
- Fix corrupt output (green artifacts) when decoding certain 10-bit content using AMD Polaris GPUs
|
||||
- Work around sequential schedule validation limit (1000/hr by Newtonsoft.Json.Schema library)
|
||||
- Playout builds now use JsonSchema.Net library which has no validation limit
|
||||
- Validation tool in the UI still uses Newtonsoft.Json.Schema (with 1000/hr limit) as the error output is easier to understand
|
||||
- Fix editing scripted and sequential playouts when using MySql
|
||||
- Fix HLS Direct streams remaining open after client disconnect
|
||||
- Always log scanner exit code when it is non-zero
|
||||
|
||||
### Changed
|
||||
- Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them
|
||||
- This mode maintains progress; progress can be reset by editing the playout and clicking `Erase Items and History`
|
||||
- Use smaller batch size for search index updates (100, down from 1000)
|
||||
- This should help newly scanned items appear in the UI more quickly
|
||||
- Replace favicon and logo in background image used for error streams
|
||||
- Block schedules:
|
||||
- Auto scroll day view to block item time when adding and removing block items from template
|
||||
- Allow keyboard selection of
|
||||
- Block groups in block list
|
||||
- Template groups in template list
|
||||
- Block groups and blocks in template editor
|
||||
- Replace template tree view with searchable table (like blocks)
|
||||
- Upgrade to dotnet 10
|
||||
|
||||
## [25.8.0] - 2025-10-26
|
||||
### Added
|
||||
- Graphics engine:
|
||||
- Add template data (like `MediaItem_Title`) for other video files
|
||||
- Add `MediaItem_Path` for movies, episodes, music videos and other videos
|
||||
- Add `get_directory_name` and `get_filename_without_extension` functions for path processing
|
||||
- Add `text_align` property to text graphics elements (values: `left`, `right` and `center`)
|
||||
- Add `MiddleCenter` value to `location` property on all graphics elements
|
||||
- Positive and negative margins can be used to offset from center as desired
|
||||
- Add `line_height` property to text element style definition
|
||||
- This is a multiplier that defaults to 1.0 when unspecified
|
||||
- Add `halo_color`, `halo_width` and `halo_blur` properties to text element style definition
|
||||
- These can be used to "outline" text with the configured color (e.g. `#000000`), width (e.g. `10`) and amount of blur (e.g. `2`)
|
||||
- Add `Block Playout Troubleshooting` tool to help investigate block playout history
|
||||
- Add sequential schedule file and scripted schedule file names to playouts table
|
||||
- Add empty (but already up-to-date) sqlite3 database to greatly speed up initial startup for fresh installs
|
||||
- Add button to copy/clone block from blocks table
|
||||
- Add playback speed to playback troubleshooting output
|
||||
- Speed is relative to realtime (1.0x is realtime)
|
||||
- Speeds < 0.9x will be colored red, between 0.9x and 1.1x colored yellow, and > 1.1x colored green
|
||||
- Add episode thumbnail artwork URL to XMLTV template
|
||||
- By default, poster will be added as image with type "poster" and thumbnail will be added as image with type "still"
|
||||
- Poster will continue to be added as icon by default
|
||||
- Add buttons to edit Jellyfin and Emby connection information in **Media Sources** > **Jellyfin** and **Media Sources** > **Emby**
|
||||
- Add audio format `aac (latm)` for DVB-C compatibility; `aac` uses ADTS by default which is required in most cases
|
||||
- Add deep scan option for external collections (Plex, Jellyfin, Emby)
|
||||
- Jellyfin and Emby collection scans have always been deep scans
|
||||
- Now, by default, they will be quick scans that trust Jellyfin and Emby's etags for detecting changes
|
||||
- If a quick scan misses updating a collection, deep scans can be triggered manually
|
||||
|
||||
### Fixed
|
||||
- Fix NVIDIA startup errors on arm64
|
||||
- Fix remote stream durations in playouts created using block, sequential or scripted schedules
|
||||
- Fix playback troubleshooting selecting a subtitle even with no subtitle stream selected in the UI
|
||||
- Fix intermittent watermark opacity
|
||||
- Improve reliability of live remote streams; they should transcode closer to realtime in most cases
|
||||
- Dramatically improve stream startup time
|
||||
- VAAPI: fix scaling image-based subtitles (e.g. dvdsub)
|
||||
- VAAPI: fix overlaying picture subtitles with scaling behavior crop
|
||||
- Fix HLS Segmenter (fmp4) on Windows
|
||||
- Playback troubleshooting: wait for at least 2 initial segments (up to configured initial segment count) to reduce stalls
|
||||
- Fix Trakt List sync
|
||||
- Fix QSV audio sync
|
||||
- Fix QSV capability detection on Linux using non-drm displays (e.g. wayland)
|
||||
- Fix playlist filtering bug that made HLS Segmenter more likely to fail when streaming for multiple hours
|
||||
- Fix NVIDIA overlaying text subtitles and permanent watermark on 10-bit content
|
||||
- Fix UI error adding deco
|
||||
- Fix UI error editing watermarks and graphics elements on blocks
|
||||
- Fix showing playout build failure details when resetting a playout
|
||||
- Fix scheduling auto-generated trakt list playlists that contain shows
|
||||
- Fix playout builder getting stuck (forever) on block item with an empty collection
|
||||
- Fix HLS Direct playback when using custom stream selector or preferred audio language/title
|
||||
- Fix selecting embedded subtitles (text and picture) with HLS Direct
|
||||
- Fix building scripted schedules across a UTC offset change
|
||||
|
||||
### Changed
|
||||
- Do not use graphics engine for single, permanent watermark
|
||||
- Rename `YAML Validation` tool to `Sequential Schedule Validation`
|
||||
- Greatly reduce debug log spam during playout builds by logging summaries of certain warnings at the end
|
||||
- Remove *experimental* `HLS Segmenter V2` streaming mode; it is not possible to maintain quality output using this mode
|
||||
- Remove *experimental* `HLS Segmenter (fmp4)` streaming mode; this mode only worked properly in a browser, many clients did not like it
|
||||
- Change how scanner process and main process communicate, which should improve reliability of search index updates when scanning
|
||||
|
||||
## [25.7.1] - 2025-10-09
|
||||
### Added
|
||||
- Add search field to filter blocks table
|
||||
- Show full error/exception details in playback troubleshooting logs
|
||||
- Add basic free space validation on startup
|
||||
- ETV will now fail to start with less than 128 MB free space in config or transcode folders
|
||||
- Add downgrade health check to inform users when they are doing something that WILL impact stability
|
||||
|
||||
### Fixed
|
||||
- Do not allow deleting ffmpeg profiles that are used by channels
|
||||
- Do not allow deleting default ffmpeg profile
|
||||
- Allow ffmpeg profiles using VAAPI accel to set h264 video profile
|
||||
- Fix HLS Direct playback, and make it accessible on separate streaming port
|
||||
- Fix playback troubleshooting when using multiple watermarks or multiple graphics elements
|
||||
|
||||
### Changed
|
||||
- Use table instead of tree view on blocks page
|
||||
- Use different release packaging system to workaround false positive from Windows Defender
|
||||
|
||||
## [25.7.0] - 2025-10-03
|
||||
### Added
|
||||
- Add new collection type `Rerun Collection`
|
||||
- This collection type will show up as *two* collection types in classic schedules
|
||||
@@ -2806,7 +2991,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.0...HEAD
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...HEAD
|
||||
[25.9.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...v25.9.0
|
||||
[25.8.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...v25.8.0
|
||||
[25.7.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.0...v25.7.1
|
||||
[25.7.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.6.0...v25.7.0
|
||||
[25.6.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.5.0...v25.6.0
|
||||
[25.5.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.4.0...v25.5.0
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
Submodule ErsatzTV-macOS updated: d4dd985fd6...8dbe1e22f2
@@ -1,30 +1,26 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Artists.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
|
||||
public class GetArtistByIdHandler(
|
||||
IArtistRepository artistRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ILanguageCodeService languageCodeService)
|
||||
: IRequestHandler<GetArtistById, Option<ArtistViewModel>>
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public GetArtistByIdHandler(IArtistRepository artistRepository, ISearchRepository searchRepository)
|
||||
{
|
||||
_artistRepository = artistRepository;
|
||||
_searchRepository = searchRepository;
|
||||
}
|
||||
|
||||
public async Task<Option<ArtistViewModel>> Handle(
|
||||
GetArtistById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Artist> maybeArtist = await _artistRepository.GetArtist(request.ArtistId);
|
||||
Option<Artist> maybeArtist = await artistRepository.GetArtist(request.ArtistId);
|
||||
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
|
||||
async artist =>
|
||||
{
|
||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
|
||||
List<string> mediaCodes = await searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = languageCodeService.GetAllLanguageCodes(mediaCodes);
|
||||
return ProjectToViewModel(artist, languageCodes);
|
||||
},
|
||||
() => Task.FromResult(Option<ArtistViewModel>.None));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.IO.Abstractions;
|
||||
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;
|
||||
@@ -12,19 +12,19 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public DeleteChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IFileSystem fileSystem,
|
||||
ISearchTargets searchTargets)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
_fileSystem = fileSystem;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_localFileSystem.FileExists(cacheFile))
|
||||
if (_fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using System.Xml;
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core;
|
||||
@@ -26,6 +27,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RefreshChannelDataHandler> _logger;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
@@ -33,12 +35,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
public RefreshChannelDataHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<RefreshChannelDataHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
_configElementRepository = configElementRepository;
|
||||
_logger = logger;
|
||||
@@ -46,247 +50,265 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
|
||||
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
int hiddenCount = await dbContext.Channels
|
||||
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
|
||||
.CountAsync(cancellationToken);
|
||||
if (hiddenCount > 0)
|
||||
try
|
||||
{
|
||||
File.Delete(targetFile);
|
||||
return;
|
||||
}
|
||||
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
|
||||
|
||||
string movieTemplateFileName = GetMovieTemplateFileName();
|
||||
string episodeTemplateFileName = GetEpisodeTemplateFileName();
|
||||
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
|
||||
string songTemplateFileName = GetSongTemplateFileName();
|
||||
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
|
||||
if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null ||
|
||||
songTemplateFileName is null || otherVideoTemplateFileName is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
var minifier = new XmlMinifier(
|
||||
new XmlMinificationSettings
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
int hiddenCount = await dbContext.Channels
|
||||
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
|
||||
.CountAsync(cancellationToken);
|
||||
if (hiddenCount > 0)
|
||||
{
|
||||
MinifyWhitespace = true,
|
||||
RemoveXmlComments = true,
|
||||
CollapseTagsWithoutContent = true
|
||||
});
|
||||
|
||||
var templateContext = new XmlTemplateContext();
|
||||
|
||||
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
|
||||
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
|
||||
|
||||
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
|
||||
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
|
||||
|
||||
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
|
||||
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
|
||||
|
||||
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
|
||||
var songTemplate = Template.Parse(songText, songTemplateFileName);
|
||||
|
||||
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
|
||||
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
|
||||
|
||||
TimeSpan playoutOffset = TimeSpan.Zero;
|
||||
string mirrorChannelNumber = null;
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Include(c => c.MirrorSourceChannel)
|
||||
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null)
|
||||
.SelectOneAsync(c => c.Number == request.ChannelNumber, c => c.Number == request.ChannelNumber, cancellationToken);
|
||||
foreach (Channel channel in maybeChannel)
|
||||
{
|
||||
mirrorChannelNumber = channel.MirrorSourceChannel.Number;
|
||||
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero;
|
||||
}
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Directors)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
int daysToBuild = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
|
||||
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
switch (playout.ScheduleKind)
|
||||
{
|
||||
case PlayoutScheduleKind.Classic:
|
||||
case PlayoutScheduleKind.Sequential:
|
||||
case PlayoutScheduleKind.Scripted:
|
||||
var floodSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
foreach (var item in floodSorted)
|
||||
{
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
floodSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
case PlayoutScheduleKind.Block:
|
||||
var blockSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
foreach (var item in blockSorted)
|
||||
{
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
await WriteBlockPlayoutXml(
|
||||
request,
|
||||
blockSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
case PlayoutScheduleKind.ExternalJson:
|
||||
var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile))
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
foreach (var item in externalJsonSorted)
|
||||
{
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
externalJsonSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
File.Delete(targetFile);
|
||||
return;
|
||||
}
|
||||
|
||||
string movieTemplateFileName = GetMovieTemplateFileName();
|
||||
string episodeTemplateFileName = GetEpisodeTemplateFileName();
|
||||
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
|
||||
string songTemplateFileName = GetSongTemplateFileName();
|
||||
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
|
||||
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
|
||||
musicVideoTemplateFileName is null ||
|
||||
songTemplateFileName is null || otherVideoTemplateFileName is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var minifier = new XmlMinifier(
|
||||
new XmlMinificationSettings
|
||||
{
|
||||
MinifyWhitespace = true,
|
||||
RemoveXmlComments = true,
|
||||
CollapseTagsWithoutContent = true
|
||||
});
|
||||
|
||||
var templateContext = new XmlTemplateContext();
|
||||
|
||||
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
|
||||
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
|
||||
|
||||
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
|
||||
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
|
||||
|
||||
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
|
||||
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
|
||||
|
||||
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
|
||||
var songTemplate = Template.Parse(songText, songTemplateFileName);
|
||||
|
||||
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
|
||||
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
|
||||
|
||||
TimeSpan playoutOffset = TimeSpan.Zero;
|
||||
string mirrorChannelNumber = null;
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Include(c => c.MirrorSourceChannel)
|
||||
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null)
|
||||
.SelectOneAsync(
|
||||
c => c.Number == request.ChannelNumber,
|
||||
c => c.Number == request.ChannelNumber,
|
||||
cancellationToken);
|
||||
foreach (Channel channel in maybeChannel)
|
||||
{
|
||||
mirrorChannelNumber = channel.MirrorSourceChannel.Number;
|
||||
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero;
|
||||
}
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Directors)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
int daysToBuild = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
|
||||
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
switch (playout.ScheduleKind)
|
||||
{
|
||||
case PlayoutScheduleKind.Classic:
|
||||
case PlayoutScheduleKind.Sequential:
|
||||
case PlayoutScheduleKind.Scripted:
|
||||
var floodSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
foreach (var item in floodSorted)
|
||||
{
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
floodSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
case PlayoutScheduleKind.Block:
|
||||
var blockSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
foreach (var item in blockSorted)
|
||||
{
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WriteBlockPlayoutXml(
|
||||
request,
|
||||
blockSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
case PlayoutScheduleKind.ExternalJson:
|
||||
var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile))
|
||||
.Filter(pi => pi.StartOffset <= finish)
|
||||
.ToList();
|
||||
foreach (var item in externalJsonSorted)
|
||||
{
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
externalJsonSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
minifier,
|
||||
xml,
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
|
||||
private async Task WritePlayoutXml(
|
||||
@@ -653,6 +675,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
showMetadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(showMetadata);
|
||||
string thumbnailPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
@@ -673,6 +696,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
|
||||
EpisodeArtworkUrl = artworkPath,
|
||||
EpisodeHasThumbnail = !string.IsNullOrWhiteSpace(thumbnailPath),
|
||||
EpisodeThumbnailUrl = thumbnailPath,
|
||||
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
|
||||
metadata.EpisodeNumber,
|
||||
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
|
||||
@@ -860,16 +885,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetMovieTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "movie.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_movie.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"movie.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -883,16 +904,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetEpisodeTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "episode.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_episode.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"episode.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -906,16 +923,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetMusicVideoTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "musicVideo.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_musicVideo.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"musicVideo.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -929,16 +942,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetSongTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "song.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_song.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"song.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -952,16 +961,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
private string GetOtherVideoTemplateFileName()
|
||||
{
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "otherVideo.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_otherVideo.sbntxt");
|
||||
}
|
||||
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
"otherVideo.sbntxt");
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV",
|
||||
@@ -1076,7 +1081,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
var result = new List<PlayoutItem>();
|
||||
|
||||
if (_localFileSystem.FileExists(path))
|
||||
if (_fileSystem.File.Exists(path))
|
||||
{
|
||||
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
|
||||
await File.ReadAllTextAsync(path));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Data.Common;
|
||||
using System.IO.Abstractions;
|
||||
using System.Net;
|
||||
using System.Xml;
|
||||
using Dapper;
|
||||
@@ -19,6 +20,7 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RefreshChannelListHandler> _logger;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
@@ -26,11 +28,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
public RefreshChannelListHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RefreshChannelListHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -44,13 +48,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
|
||||
}
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_localFileSystem.FileExists(templateFileName))
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate channel list without template file {File}; please restart ErsatzTV",
|
||||
|
||||
@@ -81,8 +81,6 @@ internal static class Mapper
|
||||
StreamingMode.TransportStreamHybrid => "MPEG-TS",
|
||||
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
||||
StreamingMode.HttpLiveStreamingSegmenterFmp4 => "HLS Segmenter (fmp4)",
|
||||
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(channel))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetAllChannels : IRequest<List<ChannelViewModel>>;
|
||||
public record GetAllChannels(bool ShowDisabled = true) : IRequest<List<ChannelViewModel>>;
|
||||
|
||||
@@ -5,17 +5,14 @@ using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
|
||||
public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetAllChannelsForApiHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public async Task<List<ChannelResponseModel>> Handle(
|
||||
GetAllChannelsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
return channels.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ public class GetAllChannelsHandler(IChannelRepository channelRepository)
|
||||
{
|
||||
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
|
||||
await channelRepository.GetAll(cancellationToken)
|
||||
.Map(list => list.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
|
||||
.Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled)
|
||||
.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
|
||||
|
||||
private static int GetPlayoutsCount(Channel channel)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelByPlayoutId(int PlayoutId) : IRequest<Option<ChannelViewModel>>;
|
||||
@@ -0,0 +1,21 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelByPlayoutId, Option<ChannelViewModel>>
|
||||
{
|
||||
public async Task<Option<ChannelViewModel>> Handle(
|
||||
GetChannelByPlayoutId request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.ThenInclude(c => c.Artwork)
|
||||
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken)
|
||||
.Map(p => ProjectToViewModel(p.Channel, 1));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
@@ -9,19 +11,22 @@ using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
|
||||
public GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
@@ -38,7 +43,7 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
.ToImmutableHashSet();
|
||||
|
||||
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!_localFileSystem.FileExists(channelsFile))
|
||||
if (!_fileSystem.File.Exists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
@@ -78,9 +83,14 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
|
||||
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
}
|
||||
|
||||
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
|
||||
private static partial Regex EtvTagRegex();
|
||||
}
|
||||
|
||||
@@ -4,16 +4,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||
public class GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
|
||||
|
||||
@@ -4,15 +4,11 @@ using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetChannelPlaylistHandler(IChannelRepository channelRepository) =>
|
||||
_channelRepository = channelRepository;
|
||||
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll(cancellationToken)
|
||||
channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(
|
||||
request.Scheme,
|
||||
@@ -38,14 +34,6 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "segmenter-fmp4":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterFmp4;
|
||||
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,5 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetMpegTsScripts : IRequest<List<MpegTsScript>>;
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetMpegTsScriptsHandler(IMpegTsScriptService mpegTsScriptService)
|
||||
: IRequestHandler<GetMpegTsScripts, List<MpegTsScript>>
|
||||
{
|
||||
public async Task<List<MpegTsScript>> Handle(GetMpegTsScripts request, CancellationToken cancellationToken)
|
||||
{
|
||||
await mpegTsScriptService.RefreshScripts();
|
||||
return mpegTsScriptService.GetScripts().OrderBy(x => x.Name).ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,43 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
|
||||
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyCollectionScannerHandler> logger) : base(
|
||||
dbContextFactory,
|
||||
configElementRepository,
|
||||
runtimeInfo,
|
||||
logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -40,7 +49,7 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -49,7 +58,7 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId, cancellationToken)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -67,20 +76,40 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.EmbyCollections);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-emby-collections", request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-collections",
|
||||
request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
return BaseError.New("Emby collections are already scanning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
@@ -15,14 +17,17 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyLibraryScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle(
|
||||
@@ -38,9 +43,9 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -53,38 +58,58 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-emby", request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby",
|
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
Option<EmbyLibrary> maybeLibrary = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
DateTime minDateTime = maybeLibrary.Match(
|
||||
l => l.LastScan ?? SystemTime.MinValueUtc,
|
||||
() => SystemTime.MaxValueUtc);
|
||||
|
||||
string libraryName = maybeLibrary.Match(l => l.Name, string.Empty);
|
||||
|
||||
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyShowById>,
|
||||
IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyShowScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallEmbyShowScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle(
|
||||
@@ -31,9 +35,9 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -46,30 +50,44 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-emby-show",
|
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-show",
|
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan(
|
||||
protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DateTimeOffset.MinValue);
|
||||
Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
@@ -12,16 +13,19 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
|
||||
private readonly IEmbyApiClient _embyApiClient;
|
||||
private readonly IEmbySecretStore _embySecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public SaveEmbySecretsHandler(
|
||||
IEmbySecretStore embySecretStore,
|
||||
IEmbyApiClient embyApiClient,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IMemoryCache memoryCache,
|
||||
ChannelWriter<IEmbyBackgroundServiceRequest> channel)
|
||||
{
|
||||
_embySecretStore = embySecretStore;
|
||||
_embyApiClient = embyApiClient;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_memoryCache = memoryCache;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
|
||||
parameters.Secrets.Address,
|
||||
parameters.ServerInformation.ServerName,
|
||||
parameters.ServerInformation.OperatingSystem);
|
||||
_memoryCache.Remove(new GetEmbyConnectionParameters());
|
||||
await _channel.WriteAsync(new SynchronizeEmbyMediaSources());
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -2,5 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
: IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
@@ -11,18 +11,18 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="4.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.9.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.0" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=emby_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpeg_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.FFmpeg;
|
||||
|
||||
public record RefreshFFmpegCapabilities : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,45 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpeg;
|
||||
|
||||
public class RefreshFFmpegCapabilitiesHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
|
||||
ILocalStatisticsProvider localStatisticsProvider)
|
||||
: IRequestHandler<RefreshFFmpegCapabilities>
|
||||
{
|
||||
public async Task Handle(RefreshFFmpegCapabilities request, CancellationToken cancellationToken)
|
||||
{
|
||||
hardwareCapabilitiesFactory.ClearCache();
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<string> maybeFFmpegPath = await dbContext.ConfigElements
|
||||
.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
|
||||
.FilterT(File.Exists);
|
||||
|
||||
foreach (string ffmpegPath in maybeFFmpegPath)
|
||||
{
|
||||
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
|
||||
|
||||
Option<string> maybeFFprobePath = await dbContext.ConfigElements
|
||||
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
|
||||
.FilterT(File.Exists);
|
||||
|
||||
foreach (string ffprobePath in maybeFFprobePath)
|
||||
{
|
||||
Either<BaseError, MediaVersion> result = await localStatisticsProvider.GetStatistics(
|
||||
ffprobePath,
|
||||
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "test.avs"));
|
||||
|
||||
hardwareCapabilitiesFactory.SetAviSynthInstalled(result.IsRight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -7,23 +8,18 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
|
||||
public class DeleteFFmpegProfileHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
|
||||
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 = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
}
|
||||
|
||||
@@ -31,10 +27,19 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
|
||||
{
|
||||
dbContext.FFmpegProfiles.Remove(ffmpegProfile);
|
||||
await dbContext.SaveChangesAsync();
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
searchTargets.SearchTargetsChanged();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken),
|
||||
await FFmpegProfileMustNotBeDefault(request, cancellationToken),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((_, _, ffmpegProfile) => ffmpegProfile);
|
||||
|
||||
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
@@ -42,4 +47,38 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
|
||||
dbContext.FFmpegProfiles
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.FFmpegProfileId == request.FFmpegProfileId)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Cannot delete FFmpeg Profile that is used by {count} {(count > 1 ? "channels" : "channel")}");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeDefault(
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> defaultFFmpegProfileId =
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
|
||||
|
||||
if (defaultFFmpegProfileId.Any(id => id == request.FFmpegProfileId))
|
||||
{
|
||||
return BaseError.New("Cannot delete default FFmpeg Profile");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.Preset;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -11,23 +9,14 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class
|
||||
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
|
||||
public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
: IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
|
||||
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
|
||||
UpdateFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
|
||||
}
|
||||
@@ -89,7 +78,7 @@ public class
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
return new UpdateFFmpegProfileResult(p.Id);
|
||||
}
|
||||
@@ -98,7 +87,8 @@ public class
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustExist(dbContext, request, cancellationToken), ValidateName(request),
|
||||
(await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateName(dbContext, request),
|
||||
ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
|
||||
@@ -111,9 +101,25 @@ public class
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
updateFFmpegProfile.NotEmpty(x => x.Name)
|
||||
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name));
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile)
|
||||
{
|
||||
if (updateFFmpegProfile.Name.Length > 50)
|
||||
{
|
||||
return BaseError.New($"FFmpeg profile name \"{updateFFmpegProfile.Name}\" is invalid");
|
||||
}
|
||||
|
||||
Option<FFmpegProfile> maybeExisting = await dbContext.FFmpegProfiles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(ff =>
|
||||
ff.Id != updateFFmpegProfile.FFmpegProfileId && ff.Name == updateFFmpegProfile.Name)
|
||||
.Map(Optional);
|
||||
|
||||
return maybeExisting.IsSome
|
||||
? BaseError.New($"An ffmpeg profile named \"{updateFFmpegProfile.Name}\" already exists in the database")
|
||||
: Success<BaseError, string>(updateFFmpegProfile.Name);
|
||||
}
|
||||
|
||||
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.FFmpeg;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
|
||||
public class UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
IFileSystem fileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
: IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateFFmpegSettings request,
|
||||
CancellationToken cancellationToken) =>
|
||||
@@ -44,7 +35,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
if (!fileSystem.File.Exists(path))
|
||||
{
|
||||
return BaseError.New($"{name} path does not exist");
|
||||
}
|
||||
@@ -71,32 +62,36 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath, cancellationToken);
|
||||
await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath, cancellationToken);
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath, cancellationToken);
|
||||
await configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath, cancellationToken);
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegDefaultProfileId,
|
||||
request.Settings.DefaultFFmpegProfileId.ToString(CultureInfo.InvariantCulture),
|
||||
cancellationToken);
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegSaveReports,
|
||||
request.Settings.SaveReports.ToString(),
|
||||
cancellationToken);
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegHlsDirectOutputFormat,
|
||||
request.Settings.HlsDirectOutputFormat,
|
||||
cancellationToken);
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegDefaultMpegTsScript,
|
||||
request.Settings.DefaultMpegTsScript,
|
||||
cancellationToken);
|
||||
|
||||
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
|
||||
{
|
||||
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegPreferredLanguageCode,
|
||||
request.Settings.PreferredAudioLanguageCode,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
|
||||
request.Settings.UseEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
@@ -107,7 +102,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
request.Settings.ExtractEmbeddedSubtitles = false;
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
|
||||
request.Settings.ExtractEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
@@ -115,48 +110,55 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
// queue extracting all embedded subtitles
|
||||
if (request.Settings.ExtractEmbeddedSubtitles)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
|
||||
}
|
||||
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegProbeForInterlacedFrames,
|
||||
request.Settings.ProbeForInterlacedFrames,
|
||||
cancellationToken);
|
||||
|
||||
if (request.Settings.GlobalWatermarkId is not null)
|
||||
{
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegGlobalWatermarkId,
|
||||
request.Settings.GlobalWatermarkId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
await configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
}
|
||||
|
||||
if (request.Settings.GlobalFallbackFillerId is not null)
|
||||
{
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegGlobalFallbackFillerId,
|
||||
request.Settings.GlobalFallbackFillerId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
|
||||
await configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegSegmenterTimeout,
|
||||
request.Settings.HlsSegmenterIdleTimeout,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegWorkAheadSegmenters,
|
||||
request.Settings.WorkAheadSegmenterLimit,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegInitialSegmentCount,
|
||||
request.Settings.InitialSegmentCount,
|
||||
cancellationToken);
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshFFmpegCapabilities(), cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public class FFmpegSettingsViewModel
|
||||
public string PreferredAudioLanguageCode { get; set; }
|
||||
public bool UseEmbeddedSubtitles { get; set; }
|
||||
public bool ExtractEmbeddedSubtitles { get; set; }
|
||||
public bool ProbeForInterlacedFrames { get; set; }
|
||||
public bool SaveReports { get; set; }
|
||||
public int? GlobalWatermarkId { get; set; }
|
||||
public int? GlobalFallbackFillerId { get; set; }
|
||||
@@ -17,4 +18,5 @@ public class FFmpegSettingsViewModel
|
||||
public int WorkAheadSegmenterLimit { get; set; }
|
||||
public int InitialSegmentCount { get; set; }
|
||||
public OutputFormatKind HlsDirectOutputFormat { get; set; }
|
||||
public string DefaultMpegTsScript { get; set; }
|
||||
}
|
||||
|
||||
@@ -47,19 +47,25 @@ internal static class Mapper
|
||||
ffmpegProfile.Id,
|
||||
ffmpegProfile.Name,
|
||||
ffmpegProfile.ThreadCount,
|
||||
(int)ffmpegProfile.HardwareAcceleration,
|
||||
ffmpegProfile.HardwareAcceleration,
|
||||
ffmpegProfile.VaapiDisplay,
|
||||
(int)ffmpegProfile.VaapiDriver,
|
||||
ffmpegProfile.VaapiDriver,
|
||||
ffmpegProfile.VaapiDevice,
|
||||
ffmpegProfile.ResolutionId,
|
||||
(int)ffmpegProfile.VideoFormat,
|
||||
ffmpegProfile.QsvExtraHardwareFrames,
|
||||
ffmpegProfile.Resolution.Name,
|
||||
ffmpegProfile.ScalingBehavior,
|
||||
ffmpegProfile.VideoFormat,
|
||||
ffmpegProfile.VideoProfile,
|
||||
ffmpegProfile.VideoPreset,
|
||||
ffmpegProfile.AllowBFrames,
|
||||
ffmpegProfile.BitDepth,
|
||||
ffmpegProfile.VideoBitrate,
|
||||
ffmpegProfile.VideoBufferSize,
|
||||
(int)ffmpegProfile.TonemapAlgorithm,
|
||||
(int)ffmpegProfile.AudioFormat,
|
||||
ffmpegProfile.TonemapAlgorithm,
|
||||
ffmpegProfile.AudioFormat,
|
||||
ffmpegProfile.AudioBitrate,
|
||||
ffmpegProfile.AudioBufferSize,
|
||||
(int)ffmpegProfile.NormalizeLoudnessMode,
|
||||
ffmpegProfile.NormalizeLoudnessMode,
|
||||
ffmpegProfile.AudioChannels,
|
||||
ffmpegProfile.AudioSampleRate,
|
||||
ffmpegProfile.NormalizeFramerate,
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public record GetAllFFmpegProfilesForApi : IRequest<List<FFmpegProfileResponseModel>>;
|
||||
public record GetAllFFmpegProfilesForApi : IRequest<List<FFmpegFullProfileResponseModel>>;
|
||||
|
||||
@@ -6,23 +6,18 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class
|
||||
GetAllFFmpegProfilesForApiHandler : IRequestHandler<GetAllFFmpegProfilesForApi, List<FFmpegProfileResponseModel>>
|
||||
public class GetAllFFmpegProfilesForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllFFmpegProfilesForApi, List<FFmpegFullProfileResponseModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllFFmpegProfilesForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<FFmpegProfileResponseModel>> Handle(
|
||||
public async Task<List<FFmpegFullProfileResponseModel>> Handle(
|
||||
GetAllFFmpegProfilesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<FFmpegProfile> ffmpegProfiles = await dbContext.FFmpegProfiles
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Resolution)
|
||||
.ToListAsync(cancellationToken);
|
||||
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
|
||||
return ffmpegProfiles.Map(ProjectToFullResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,41 +4,59 @@ using ErsatzTV.FFmpeg.OutputFormat;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpegSettingsViewModel>
|
||||
public class GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetFFmpegSettings, FFmpegSettingsViewModel>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<FFmpegSettingsViewModel> Handle(
|
||||
GetFFmpegSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> ffmpegPath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken);
|
||||
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken);
|
||||
Option<string> ffmpegPath = await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.FFmpegPath,
|
||||
cancellationToken);
|
||||
Option<string> ffprobePath = await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.FFprobePath,
|
||||
cancellationToken);
|
||||
Option<int> defaultFFmpegProfileId =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
|
||||
Option<bool> saveReports =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken);
|
||||
await configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken);
|
||||
Option<string> preferredAudioLanguageCode =
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode, cancellationToken);
|
||||
await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.FFmpegPreferredLanguageCode,
|
||||
cancellationToken);
|
||||
Option<bool> useEmbeddedSubtitles =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken);
|
||||
await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
Option<bool> extractEmbeddedSubtitles =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken);
|
||||
await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
|
||||
cancellationToken);
|
||||
Option<bool> probeForInterlacedFrames =
|
||||
await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.FFmpegProbeForInterlacedFrames,
|
||||
cancellationToken);
|
||||
Option<int> watermark =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
|
||||
Option<int> fallbackFiller =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.FFmpegGlobalFallbackFillerId,
|
||||
cancellationToken);
|
||||
Option<int> hlsSegmenterIdleTimeout =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken);
|
||||
Option<int> workAheadSegmenterLimit =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken);
|
||||
Option<int> initialSegmentCount =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken);
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken);
|
||||
Option<OutputFormatKind> outputFormatKind =
|
||||
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
|
||||
await configElementRepository.GetValue<OutputFormatKind>(
|
||||
ConfigElementKey.FFmpegHlsDirectOutputFormat,
|
||||
cancellationToken);
|
||||
Option<string> defaultMpegTsScript =
|
||||
await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.FFmpegDefaultMpegTsScript,
|
||||
cancellationToken);
|
||||
|
||||
var result = new FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -48,11 +66,13 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
|
||||
SaveReports = await saveReports.IfNoneAsync(false),
|
||||
UseEmbeddedSubtitles = await useEmbeddedSubtitles.IfNoneAsync(true),
|
||||
ExtractEmbeddedSubtitles = await extractEmbeddedSubtitles.IfNoneAsync(false),
|
||||
ProbeForInterlacedFrames = await probeForInterlacedFrames.IfNoneAsync(false),
|
||||
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
|
||||
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
|
||||
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
|
||||
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1),
|
||||
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs)
|
||||
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.MpegTs),
|
||||
DefaultMpegTsScript = await defaultMpegTsScript.IfNoneAsync("Default"),
|
||||
};
|
||||
|
||||
foreach (int watermarkId in watermark)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -9,7 +11,9 @@ namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public class RefreshGraphicsElementsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IGraphicsElementLoader graphicsElementLoader,
|
||||
ILogger<RefreshGraphicsElementsHandler> logger)
|
||||
: IRequestHandler<RefreshGraphicsElements>
|
||||
{
|
||||
@@ -21,7 +25,11 @@ public class RefreshGraphicsElementsHandler(
|
||||
List<GraphicsElement> allExisting = await dbContext.GraphicsElements
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (GraphicsElement existing in allExisting.Where(e => !localFileSystem.FileExists(e.Path)))
|
||||
var missing = allExisting
|
||||
.Where(e => !fileSystem.File.Exists(e.Path) || (Path.GetExtension(e.Path) != ".yml" && Path.GetExtension(e.Path) != ".yaml"))
|
||||
.ToList();
|
||||
|
||||
foreach (GraphicsElement existing in missing)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Removing graphics element that references non-existing file {File}",
|
||||
@@ -30,8 +38,13 @@ public class RefreshGraphicsElementsHandler(
|
||||
dbContext.GraphicsElements.Remove(existing);
|
||||
}
|
||||
|
||||
foreach (GraphicsElement existing in allExisting.Except(missing))
|
||||
{
|
||||
await TryRefreshName(existing, cancellationToken);
|
||||
}
|
||||
|
||||
// add new text elements
|
||||
var newTextPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder)
|
||||
var newTextPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.yml", "*.yaml")
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
@@ -45,11 +58,13 @@ public class RefreshGraphicsElementsHandler(
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
await TryRefreshName(graphicsElement, cancellationToken);
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
// add new image elements
|
||||
var newImagePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsImageTemplatesFolder)
|
||||
var newImagePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsImageTemplatesFolder, "*.yml", "*.yaml")
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
@@ -63,11 +78,13 @@ public class RefreshGraphicsElementsHandler(
|
||||
Kind = GraphicsElementKind.Image
|
||||
};
|
||||
|
||||
await TryRefreshName(graphicsElement, cancellationToken);
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
// add new motion elements
|
||||
var newMotionPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsMotionTemplatesFolder)
|
||||
var newMotionPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsMotionTemplatesFolder, "*.yml", "*.yaml")
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
@@ -81,11 +98,13 @@ public class RefreshGraphicsElementsHandler(
|
||||
Kind = GraphicsElementKind.Motion
|
||||
};
|
||||
|
||||
await TryRefreshName(graphicsElement, cancellationToken);
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
// add new subtitle elements
|
||||
var newSubtitlePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder)
|
||||
var newSubtitlePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder, "*.yml", "*.yaml")
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
@@ -99,9 +118,21 @@ public class RefreshGraphicsElementsHandler(
|
||||
Kind = GraphicsElementKind.Subtitle
|
||||
};
|
||||
|
||||
await TryRefreshName(graphicsElement, cancellationToken);
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task TryRefreshName(GraphicsElement graphicsElement, CancellationToken cancellationToken)
|
||||
{
|
||||
graphicsElement.Name = null;
|
||||
Option<string> maybeName = await graphicsElementLoader.TryLoadName(graphicsElement.Path, cancellationToken);
|
||||
foreach (string name in maybeName)
|
||||
{
|
||||
graphicsElement.Name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record GraphicsElementViewModel(int Id, string Name);
|
||||
public record GraphicsElementViewModel(int Id, string Name, string FileName);
|
||||
|
||||
@@ -7,13 +7,21 @@ public static class Mapper
|
||||
public static GraphicsElementViewModel ProjectToViewModel(GraphicsElement graphicsElement)
|
||||
{
|
||||
string fileName = Path.GetFileName(graphicsElement.Path);
|
||||
return graphicsElement.Kind switch
|
||||
fileName = graphicsElement.Kind switch
|
||||
{
|
||||
GraphicsElementKind.Text => new GraphicsElementViewModel(graphicsElement.Id, $"text/{fileName}"),
|
||||
GraphicsElementKind.Image => new GraphicsElementViewModel(graphicsElement.Id, $"image/{fileName}"),
|
||||
GraphicsElementKind.Subtitle => new GraphicsElementViewModel(graphicsElement.Id, $"subtitle/{fileName}"),
|
||||
GraphicsElementKind.Motion => new GraphicsElementViewModel(graphicsElement.Id, $"motion/{fileName}"),
|
||||
_ => new GraphicsElementViewModel(graphicsElement.Id, graphicsElement.Path)
|
||||
GraphicsElementKind.Text => $"text/{fileName}",
|
||||
GraphicsElementKind.Image => $"image/{fileName}",
|
||||
GraphicsElementKind.Subtitle => $"subtitle/{fileName}",
|
||||
GraphicsElementKind.Motion => $"motion/{fileName}",
|
||||
_ => graphicsElement.Path
|
||||
};
|
||||
|
||||
string name = fileName;
|
||||
if (!string.IsNullOrWhiteSpace(graphicsElement.Name))
|
||||
{
|
||||
name = $"{graphicsElement.Name} ({fileName})";
|
||||
}
|
||||
|
||||
return new GraphicsElementViewModel(graphicsElement.Id, name, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ public class GetAllGraphicsElementsHandler(IDbContextFactory<TvContext> dbContex
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.GraphicsElements
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
.Map(list => list.Map(ProjectToViewModel).OrderBy(e => e.Name == e.FileName).ThenBy(e => e.Name).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinCollections>,
|
||||
IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallJellyfinCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallJellyfinCollectionScannerHandler> logger) : base(
|
||||
dbContextFactory,
|
||||
configElementRepository,
|
||||
runtimeInfo,
|
||||
logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -40,7 +49,7 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeJellyfinCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -49,7 +58,7 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId, cancellationToken)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -67,20 +76,40 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizeJellyfinCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.JellyfinCollections);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-jellyfin-collections", request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-jellyfin-collections",
|
||||
request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId),
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
return BaseError.New("Jellyfin collections are already scanning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
@@ -15,14 +17,17 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallJellyfinLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallJellyfinLibraryScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>.
|
||||
@@ -39,9 +44,9 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -54,38 +59,58 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.JellyfinLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-jellyfin", request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-jellyfin",
|
||||
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return BaseError.New($"Library {request.JellyfinLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.JellyfinLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId, cancellationToken)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
Option<JellyfinLibrary> maybeLibrary = await dbContext.JellyfinLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId, cancellationToken);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
DateTime minDateTime = maybeLibrary.Match(
|
||||
l => l.LastScan ?? SystemTime.MinValueUtc,
|
||||
() => SystemTime.MaxValueUtc);
|
||||
|
||||
string libraryName = maybeLibrary.Match(l => l.Name, string.Empty);
|
||||
|
||||
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinShowById>,
|
||||
IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallJellyfinShowScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallJellyfinShowScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>.Handle(
|
||||
@@ -31,9 +35,9 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
|
||||
SynchronizeJellyfinShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -46,30 +50,44 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizeJellyfinShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.JellyfinLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-jellyfin-show",
|
||||
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-jellyfin-show",
|
||||
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return BaseError.New($"Library {request.JellyfinLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan(
|
||||
protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeJellyfinShowById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DateTimeOffset.MinValue);
|
||||
Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
@@ -12,16 +13,19 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public SaveJellyfinSecretsHandler(
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IMemoryCache memoryCache,
|
||||
ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
|
||||
{
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_memoryCache = memoryCache;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
|
||||
parameters.Secrets.Address,
|
||||
parameters.ServerInformation.ServerName,
|
||||
parameters.ServerInformation.OperatingSystem);
|
||||
_memoryCache.Remove(new GetJellyfinConnectionParameters());
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinMediaSources());
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) :
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan) :
|
||||
IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,52 +1,31 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Channels;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.MediaSources;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Compact.Reader;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public abstract class CallLibraryScannerHandler<TRequest>
|
||||
public abstract class CallLibraryScannerHandler<TRequest>(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger logger)
|
||||
{
|
||||
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(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_configElementRepository = configElementRepository;
|
||||
_channel = channel;
|
||||
_mediator = mediator;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/scan/{scanId}";
|
||||
|
||||
protected async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
List<string> arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -55,39 +34,27 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
using var forcefulCts = new CancellationTokenSource();
|
||||
|
||||
await using CancellationTokenRegistration link =
|
||||
cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10))
|
||||
);
|
||||
cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10)));
|
||||
|
||||
CommandResult process = await Cli.Wrap(scanner)
|
||||
CommandResult process = await Cli.Wrap(parameters.Scanner)
|
||||
.WithArguments(arguments)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithStandardErrorPipe(PipeTarget.ToDelegate(ProcessLogOutput))
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(ProcessProgressOutput))
|
||||
.WithStandardOutputPipe(PipeTarget.Null)
|
||||
.ExecuteAsync(forcefulCts.Token, cancellationToken);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
logger.LogWarning("ErsatzTV.Scanner exited with code {ExitCode}", process.ExitCode);
|
||||
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)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return _libraryName ?? string.Empty;
|
||||
return parameters.LibraryName;
|
||||
}
|
||||
|
||||
private static void ProcessLogOutput(string s)
|
||||
@@ -101,7 +68,7 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
// writes in UTC
|
||||
LogEvent logEvent = LogEventReader.ReadFromString(s);
|
||||
|
||||
ILogger log = Log.Logger;
|
||||
Serilog.ILogger log = Log.Logger;
|
||||
if (logEvent.Properties.TryGetValue("SourceContext", out LogEventPropertyValue property))
|
||||
{
|
||||
log = log.ForContext(
|
||||
@@ -124,94 +91,59 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessProgressOutput(string s)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
try
|
||||
{
|
||||
ScannerProgressUpdate progressUpdate = JsonConvert.DeserializeObject<ScannerProgressUpdate>(s);
|
||||
if (progressUpdate != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(progressUpdate.LibraryName))
|
||||
{
|
||||
_libraryName = progressUpdate.LibraryName;
|
||||
}
|
||||
|
||||
_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(
|
||||
progressUpdate.LibraryId,
|
||||
progressUpdate.PercentComplete.Value);
|
||||
|
||||
await _mediator.Publish(progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "Unable to process scanner progress update");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract Task<DateTimeOffset> GetLastScan(
|
||||
protected abstract Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
TRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
|
||||
|
||||
protected async Task<Validation<BaseError, string>> Validate(TRequest request, CancellationToken cancellationToken)
|
||||
protected async Task<Validation<BaseError, ScanParameters>> Validate(TRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
int libraryRefreshInterval = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
|
||||
.IfNoneAsync(0);
|
||||
|
||||
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
DateTimeOffset lastScan = await GetLastScan(dbContext, request, cancellationToken);
|
||||
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
|
||||
try
|
||||
{
|
||||
return new ScanIsNotRequired();
|
||||
}
|
||||
int libraryRefreshInterval = await configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
|
||||
.IfNoneAsync(0);
|
||||
|
||||
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
|
||||
? "ErsatzTV.Scanner.exe"
|
||||
: "ErsatzTV.Scanner";
|
||||
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
|
||||
|
||||
string processFileName = Environment.ProcessPath ?? string.Empty;
|
||||
string processExecutable = Path.GetFileNameWithoutExtension(processFileName);
|
||||
string folderName = Path.GetDirectoryName(processFileName);
|
||||
if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
folderName = AppContext.BaseDirectory;
|
||||
}
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(folderName))
|
||||
{
|
||||
string localFileName = Path.Combine(folderName, executable);
|
||||
if (File.Exists(localFileName))
|
||||
(string libraryName, DateTimeOffset lastScan) = await GetLastScan(dbContext, request, cancellationToken);
|
||||
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
|
||||
{
|
||||
return localFileName;
|
||||
return new ScanIsNotRequired();
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New("Unable to locate ErsatzTV.Scanner executable");
|
||||
string executable = runtimeInfo.IsOSPlatform(OSPlatform.Windows)
|
||||
? "ErsatzTV.Scanner.exe"
|
||||
: "ErsatzTV.Scanner";
|
||||
|
||||
string processFileName = Environment.ProcessPath ?? string.Empty;
|
||||
string processExecutable = Path.GetFileNameWithoutExtension(processFileName);
|
||||
string folderName = Path.GetDirectoryName(processFileName);
|
||||
if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
folderName = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(folderName))
|
||||
{
|
||||
string localFileName = Path.Combine(folderName, executable);
|
||||
if (File.Exists(localFileName))
|
||||
{
|
||||
return new ScanParameters(libraryName, localFileName);
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New("Unable to locate ErsatzTV.Scanner executable");
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return BaseError.New("Scan was canceled");
|
||||
}
|
||||
}
|
||||
|
||||
protected sealed record ScanParameters(string LibraryName, string Scanner);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -15,20 +15,23 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILanguageCodeService _languageCodeService;
|
||||
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public MoveLocalLibraryPathHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILanguageCodeService languageCodeService,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MoveLocalLibraryPathHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_languageCodeService = languageCodeService;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -64,6 +67,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
_languageCodeService,
|
||||
[mediaItem]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ internal static class Mapper
|
||||
return new SongCardViewModel(
|
||||
songMetadata.SongId,
|
||||
songMetadata.Title,
|
||||
string.Join(", ", songMetadata.Artists) + album,
|
||||
string.Join(", ", songMetadata.Artists ?? []) + album,
|
||||
songMetadata.SortTitle,
|
||||
GetThumbnail(songMetadata, None, None),
|
||||
songMetadata.Song.State);
|
||||
|
||||
@@ -3,7 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -19,13 +19,14 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
|
||||
|
||||
public AddTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILanguageCodeService languageCodeService,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<AddTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, languageCodeService, logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record CreateSmartCollectionResult(int SmartCollectionId) : EntityIdResult(SmartCollectionId);
|
||||
@@ -2,7 +2,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -16,22 +16,25 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILanguageCodeService _languageCodeService;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public DeleteTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILanguageCodeService languageCodeService,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<DeleteTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, languageCodeService, logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_languageCodeService = languageCodeService;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
@@ -65,6 +68,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
_languageCodeService,
|
||||
mediaItemIds,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -19,9 +19,10 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||
|
||||
public MatchTraktListItemsHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILanguageCodeService languageCodeService,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MatchTraktListItemsHandler> logger,
|
||||
IEntityLocker entityLocker) : base(
|
||||
@@ -29,6 +30,7 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
languageCodeService,
|
||||
logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record PreviewPlaylistPlayout(ReplacePlaylistItems Data) : IRequest<List<PlayoutItemPreviewViewModel>>;
|
||||
public record PreviewPlaylistPlayout(ReplacePlaylistItems Data)
|
||||
: IRequest<Either<BaseError, List<PlayoutItemPreviewViewModel>>>;
|
||||
|
||||
@@ -17,9 +17,9 @@ public class PreviewPlaylistPlayoutHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
IPlayoutBuilder playoutBuilder)
|
||||
: IRequestHandler<PreviewPlaylistPlayout, List<PlayoutItemPreviewViewModel>>
|
||||
: IRequestHandler<PreviewPlaylistPlayout, Either<BaseError, List<PlayoutItemPreviewViewModel>>>
|
||||
{
|
||||
public async Task<List<PlayoutItemPreviewViewModel>> Handle(
|
||||
public async Task<Either<BaseError, List<PlayoutItemPreviewViewModel>>> Handle(
|
||||
PreviewPlaylistPlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -65,7 +65,7 @@ public class PreviewPlaylistPlayoutHandler(
|
||||
PlayoutBuildMode.Reset,
|
||||
cancellationToken);
|
||||
|
||||
return await buildResult.MatchAsync(
|
||||
return await buildResult.MatchAsync<Either<BaseError, List<PlayoutItemPreviewViewModel>>>(
|
||||
async result =>
|
||||
{
|
||||
var maxItems = 0;
|
||||
@@ -121,7 +121,7 @@ public class PreviewPlaylistPlayoutHandler(
|
||||
|
||||
return onceThrough.OrderBy(i => i.StartOffset).Map(Scheduling.Mapper.ProjectToViewModel).ToList();
|
||||
},
|
||||
_ => []);
|
||||
error => error);
|
||||
}
|
||||
|
||||
private static ProgramScheduleItemFlood MapToScheduleItem(PreviewPlaylistPlayout request) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
@@ -15,20 +15,23 @@ namespace ErsatzTV.Application.MediaCollections;
|
||||
public abstract class TraktCommandBase
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILanguageCodeService _languageCodeService;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
protected TraktCommandBase(
|
||||
ITraktApiClient traktApiClient,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILanguageCodeService languageCodeService,
|
||||
ILogger logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_languageCodeService = languageCodeService;
|
||||
_logger = logger;
|
||||
|
||||
TraktApiClient = traktApiClient;
|
||||
@@ -195,7 +198,8 @@ public abstract class TraktCommandBase
|
||||
Index = item.Rank,
|
||||
PlaylistId = list.Playlist.Id,
|
||||
Playlist = list.Playlist,
|
||||
IncludeInProgramGuide = true
|
||||
IncludeInProgramGuide = true,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
};
|
||||
|
||||
await dbContext.PlaylistItems.AddAsync(playlistItem, cancellationToken);
|
||||
@@ -227,6 +231,7 @@ public abstract class TraktCommandBase
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
_languageCodeService,
|
||||
ids.ToList(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record UpdateSmartCollection(int Id, string Name, string Query) : IRequest<Either<BaseError, Unit>>;
|
||||
public record UpdateSmartCollection(int Id, string Name, string Query)
|
||||
: IRequest<Either<BaseError, UpdateSmartCollectionResult>>;
|
||||
|
||||
@@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
|
||||
public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, UpdateSmartCollectionResult>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
@@ -34,7 +34,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
|
||||
_smartCollectionCache = smartCollectionCache;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
public async Task<Either<BaseError, UpdateSmartCollectionResult>> Handle(
|
||||
UpdateSmartCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -43,7 +43,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
SmartCollection c,
|
||||
UpdateSmartCollection request,
|
||||
@@ -65,7 +65,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
return new UpdateSmartCollectionResult(c.Id);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, SmartCollection>> Validate(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record UpdateSmartCollectionResult(int SmartCollectionId) : EntityIdResult(SmartCollectionId);
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.Tree;
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
@@ -23,6 +24,9 @@ internal static class Mapper
|
||||
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
|
||||
new(collection.Id, collection.Name, collection.Query);
|
||||
|
||||
internal static SmartCollectionResponseModel ProjectToResponseModel(SmartCollection collection) =>
|
||||
new(collection.Id, collection.Name, collection.Query);
|
||||
|
||||
internal static RerunCollectionViewModel ProjectToViewModel(RerunCollection collection) =>
|
||||
new(
|
||||
collection.Id,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record GetAllSmartCollectionsForApi : IRequest<List<SmartCollectionResponseModel>>;
|
||||
@@ -0,0 +1,22 @@
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class GetAllSmartCollectionsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllSmartCollectionsForApi, List<SmartCollectionResponseModel>>
|
||||
{
|
||||
public async Task<List<SmartCollectionResponseModel>> Handle(
|
||||
GetAllSmartCollectionsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<SmartCollection> ffmpegProfiles = await dbContext.SmartCollections
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public record MediaItemInfo(
|
||||
string DisplayAspectRatio,
|
||||
string RFrameRate,
|
||||
VideoScanKind VideoScanKind,
|
||||
double? InterlacedRatio,
|
||||
int Width,
|
||||
int Height,
|
||||
List<MediaItemInfoStream> Streams,
|
||||
|
||||
@@ -147,6 +147,7 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
|
||||
version.DisplayAspectRatio,
|
||||
version.RFrameRate,
|
||||
version.VideoScanKind,
|
||||
version.InterlacedRatio,
|
||||
version.Width,
|
||||
version.Height,
|
||||
allStreams,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
@@ -15,14 +17,17 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
|
||||
IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
|
||||
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallLocalLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallLocalLibraryScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>.Handle(
|
||||
@@ -35,9 +40,9 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(IScanLocalLibrary request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -50,24 +55,39 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
IScanLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.LibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-local", request.LibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-local",
|
||||
request.LibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return BaseError.New($"Library {request.LibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
IScanLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -80,7 +100,11 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
|
||||
? libraryPaths.Min(lp => lp.LastScan ?? SystemTime.MinValueUtc)
|
||||
: SystemTime.MaxValueUtc;
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
string libraryName = await dbContext.Libraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.LibraryId, cancellationToken)
|
||||
.Match(l => l.Name, () => string.Empty);
|
||||
|
||||
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Movies.Mapper;
|
||||
|
||||
@@ -15,6 +15,7 @@ public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieVie
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly ILanguageCodeService _languageCodeService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
@@ -26,7 +27,8 @@ public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieVie
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService)
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
ILanguageCodeService languageCodeService)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_movieRepository = movieRepository;
|
||||
@@ -34,6 +36,7 @@ public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieVie
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_languageCodeService = languageCodeService;
|
||||
}
|
||||
|
||||
public async Task<Option<MovieViewModel>> Handle(
|
||||
@@ -59,7 +62,7 @@ public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieVie
|
||||
.Map(ms => ms.Language)
|
||||
.ToList();
|
||||
|
||||
languageCodes.AddRange(await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCodes));
|
||||
languageCodes.AddRange(_languageCodeService.GetAllLanguageCodes(mediaCodes));
|
||||
}
|
||||
|
||||
foreach (Movie movie in maybeMovie)
|
||||
|
||||
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
@@ -29,6 +30,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
private readonly IPlayoutBuilder _playoutBuilder;
|
||||
private readonly IPlayoutTimeShifter _playoutTimeShifter;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly ILogger<BuildPlayoutHandler> _logger;
|
||||
private readonly ISequentialPlayoutBuilder _sequentialPlayoutBuilder;
|
||||
private readonly IScriptedPlayoutBuilder _scriptedPlayoutBuilder;
|
||||
|
||||
@@ -44,7 +46,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IEntityLocker entityLocker,
|
||||
IPlayoutTimeShifter playoutTimeShifter,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILogger<BuildPlayoutHandler> logger)
|
||||
{
|
||||
_client = client;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
@@ -58,6 +61,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
_entityLocker = entityLocker;
|
||||
_playoutTimeShifter = playoutTimeShifter;
|
||||
_workerChannel = workerChannel;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
|
||||
@@ -88,6 +92,38 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
{
|
||||
await _playoutTimeShifter.TimeShift(request.PlayoutId, timeShiftTo, false, cancellationToken);
|
||||
}
|
||||
|
||||
if (playoutBuildResult.Warnings.TailFillerTooLong > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Playout {PlayoutId} skipped {Count} tail filler items that were too long to fit",
|
||||
request.PlayoutId,
|
||||
playoutBuildResult.Warnings.TailFillerTooLong);
|
||||
}
|
||||
|
||||
if (playoutBuildResult.Warnings.MidRollContentWithoutChapters > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Playout {PlayoutId} converted mid-roll to post-roll for {Count} items that have no chapter markers",
|
||||
request.PlayoutId,
|
||||
playoutBuildResult.Warnings.MidRollContentWithoutChapters);
|
||||
}
|
||||
|
||||
if (playoutBuildResult.Warnings.DurationFillerSkipped > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Playout {PlayoutId} skipped {Count} filler items to try to fit in a small remaining duration",
|
||||
request.PlayoutId,
|
||||
playoutBuildResult.Warnings.DurationFillerSkipped);
|
||||
}
|
||||
|
||||
if (playoutBuildResult.Warnings.BlockItemSkippedEmptyCollection > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Playout {PlayoutId} skipped {Count} block items due to empty collections",
|
||||
request.PlayoutId,
|
||||
playoutBuildResult.Warnings.BlockItemSkippedEmptyCollection);
|
||||
}
|
||||
}
|
||||
|
||||
return result.Map(_ => Unit.Default);
|
||||
@@ -262,7 +298,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
// and therefore the segmenter may need to seek into the next item instead of
|
||||
// starting at the beginning (if already working ahead)
|
||||
changeCount += await dbContext.SaveChangesAsync(cancellationToken);
|
||||
bool hasChanges = changeCount > 0;
|
||||
bool hasChanges = changeCount > 0 || referenceData.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand;
|
||||
|
||||
if (request.Mode != PlayoutBuildMode.Continue && hasChanges)
|
||||
{
|
||||
@@ -331,8 +367,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
{
|
||||
try
|
||||
{
|
||||
await dbContext.PlayoutBuildStatus.AddAsync(newBuildStatus, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await dbContext.PlayoutBuildStatus.AddAsync(newBuildStatus, CancellationToken.None);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -14,16 +14,16 @@ namespace ErsatzTV.Application.Playouts;
|
||||
public class CreateExternalJsonPlayoutHandler
|
||||
: IRequestHandler<CreateExternalJsonPlayout, Either<BaseError, CreatePlayoutResponse>>
|
||||
{
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public CreateExternalJsonPlayoutHandler(
|
||||
ILocalFileSystem localFileSystem,
|
||||
IFileSystem fileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_fileSystem = fileSystem;
|
||||
_channel = channel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public class CreateExternalJsonPlayoutHandler
|
||||
|
||||
private Validation<BaseError, string> ValidateExternalJsonFile(CreateExternalJsonPlayout request)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(request.ScheduleFile))
|
||||
if (!_fileSystem.File.Exists(request.ScheduleFile))
|
||||
{
|
||||
return BaseError.New("External Json File does not exist!");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.CommandLine.Parsing;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
@@ -12,28 +13,17 @@ using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class CreateScriptedPlayoutHandler
|
||||
public class CreateScriptedPlayoutHandler(
|
||||
IFileSystem fileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<CreateScriptedPlayout, Either<BaseError, CreatePlayoutResponse>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public CreateScriptedPlayoutHandler(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_channel = channel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
|
||||
CreateScriptedPlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(playout => PersistPlayout(dbContext, playout, cancellationToken));
|
||||
}
|
||||
@@ -45,15 +35,15 @@ public class CreateScriptedPlayoutHandler
|
||||
{
|
||||
await dbContext.Playouts.AddAsync(playout, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
|
||||
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
|
||||
{
|
||||
await _channel.WriteAsync(
|
||||
await channel.WriteAsync(
|
||||
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await _channel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
@@ -91,7 +81,7 @@ public class CreateScriptedPlayoutHandler
|
||||
{
|
||||
var args = CommandLineParser.SplitCommandLine(request.ScheduleFile).ToList();
|
||||
string scriptFile = args[0];
|
||||
if (!_localFileSystem.FileExists(scriptFile))
|
||||
if (!fileSystem.File.Exists(scriptFile))
|
||||
{
|
||||
return BaseError.New("Scripted schedule does not exist!");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -11,28 +11,17 @@ using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class CreateSequentialPlayoutHandler
|
||||
public class CreateSequentialPlayoutHandler(
|
||||
IFileSystem fileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<CreateSequentialPlayout, Either<BaseError, CreatePlayoutResponse>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public CreateSequentialPlayoutHandler(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_channel = channel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
|
||||
CreateSequentialPlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(playout => PersistPlayout(dbContext, playout, cancellationToken));
|
||||
}
|
||||
@@ -44,15 +33,15 @@ public class CreateSequentialPlayoutHandler
|
||||
{
|
||||
await dbContext.Playouts.AddAsync(playout, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
|
||||
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
|
||||
{
|
||||
await _channel.WriteAsync(
|
||||
await channel.WriteAsync(
|
||||
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await _channel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
@@ -87,7 +76,7 @@ public class CreateSequentialPlayoutHandler
|
||||
|
||||
private Validation<BaseError, string> ValidateYamlFile(CreateSequentialPlayout request)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(request.ScheduleFile))
|
||||
if (!fileSystem.File.Exists(request.ScheduleFile))
|
||||
{
|
||||
return BaseError.New("Sequential schedule does not exist!");
|
||||
}
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
using System.Threading.Channels;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseError, Unit>>
|
||||
public class DeletePlayoutHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<DeletePlayout, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public DeletePlayoutHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(DeletePlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
@@ -40,13 +32,15 @@ public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseEr
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml");
|
||||
if (_localFileSystem.FileExists(cacheFile))
|
||||
if (fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
// refresh channel list to remove channel that has no playout
|
||||
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
await mediator.Publish(new PlayoutUpdatedNotification(playout.Id, false), cancellationToken);
|
||||
}
|
||||
|
||||
return maybePlayout
|
||||
|
||||
@@ -24,8 +24,8 @@ public class InsertPlayoutGapsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
PlayoutItem one = queue.Dequeue();
|
||||
PlayoutItem two = queue.Peek();
|
||||
|
||||
DateTimeOffset start = one.FinishOffset;
|
||||
DateTimeOffset finish = two.StartOffset;
|
||||
DateTime start = one.Finish;
|
||||
DateTime finish = two.Start;
|
||||
|
||||
if (start == finish)
|
||||
{
|
||||
@@ -35,8 +35,8 @@ public class InsertPlayoutGapsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
var gap = new PlayoutGap
|
||||
{
|
||||
PlayoutId = request.PlayoutId,
|
||||
Start = start.UtcDateTime,
|
||||
Finish = finish.UtcDateTime
|
||||
Start = start,
|
||||
Finish = finish
|
||||
};
|
||||
|
||||
toAdd.Add(gap);
|
||||
|
||||
@@ -22,6 +22,14 @@ public class ResetAllPlayoutsHandler(
|
||||
switch (playout.ScheduleKind)
|
||||
{
|
||||
case PlayoutScheduleKind.Classic:
|
||||
if (!locker.IsPlayoutLocked(playout.Id))
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
break;
|
||||
case PlayoutScheduleKind.Block:
|
||||
case PlayoutScheduleKind.Sequential:
|
||||
case PlayoutScheduleKind.Scripted:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.CommandLine.Parsing;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -14,7 +14,7 @@ public class
|
||||
UpdateScriptedPlayoutHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILocalFileSystem localFileSystem)
|
||||
IFileSystem fileSystem)
|
||||
: IRequestHandler<UpdateScriptedPlayout,
|
||||
Either<BaseError, PlayoutNameViewModel>>
|
||||
{
|
||||
@@ -63,7 +63,7 @@ public class
|
||||
{
|
||||
var args = CommandLineParser.SplitCommandLine(request.ScheduleFile).ToList();
|
||||
string scriptFile = args[0];
|
||||
if (!localFileSystem.FileExists(scriptFile))
|
||||
if (!fileSystem.File.Exists(scriptFile))
|
||||
{
|
||||
return BaseError.New("Scripted schedule does not exist!");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
@@ -34,6 +35,14 @@ internal static class Mapper
|
||||
programScheduleAlternate.DaysOfMonth,
|
||||
programScheduleAlternate.MonthsOfYear);
|
||||
|
||||
internal static PlayoutHistoryViewModel ProjectToViewModel(PlayoutHistory playoutHistory) =>
|
||||
new(
|
||||
playoutHistory.Id,
|
||||
new DateTimeOffset(playoutHistory.When, TimeSpan.Zero).ToLocalTime(),
|
||||
new DateTimeOffset(playoutHistory.Finish, TimeSpan.Zero).ToLocalTime(),
|
||||
playoutHistory.Key,
|
||||
playoutHistory.Details);
|
||||
|
||||
internal static string GetDisplayTitle(MediaItem mediaItem, Option<string> maybeChapterTitle)
|
||||
{
|
||||
string chapterTitle = maybeChapterTitle.IfNone(string.Empty);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record PagedPlayoutHistoryViewModel(int TotalCount, List<PlayoutHistoryViewModel> Page);
|
||||
3
ErsatzTV.Application/Playouts/PlayoutHistoryViewModel.cs
Normal file
3
ErsatzTV.Application/Playouts/PlayoutHistoryViewModel.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record PlayoutHistoryViewModel(int Id, DateTimeOffset When, DateTimeOffset Finish, string Key, string Details);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetAllBlockPlayouts : IRequest<List<PlayoutNameViewModel>>;
|
||||
@@ -0,0 +1,26 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetAllBlockPlayoutsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllBlockPlayouts, List<PlayoutNameViewModel>>
|
||||
{
|
||||
public async Task<List<PlayoutNameViewModel>> Handle(
|
||||
GetAllBlockPlayouts request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.Include(p => p.BuildStatus)
|
||||
.Where(p => p.Channel != null && p.ScheduleKind == PlayoutScheduleKind.Block)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return playouts.Map(Mapper.ProjectToViewModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetBlockPlayoutHistory(int PlayoutId, int BlockId, int PageNum, int PageSize)
|
||||
: IRequest<PagedPlayoutHistoryViewModel>;
|
||||
@@ -0,0 +1,30 @@
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetBlockPlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetBlockPlayoutHistory, PagedPlayoutHistoryViewModel>
|
||||
{
|
||||
public async Task<PagedPlayoutHistoryViewModel> Handle(
|
||||
GetBlockPlayoutHistory request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
IQueryable<PlayoutHistory> query = dbContext.PlayoutHistory
|
||||
.AsNoTracking()
|
||||
.Where(ph => ph.PlayoutId == request.PlayoutId && ph.BlockId == request.BlockId);
|
||||
|
||||
int totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
List<PlayoutHistory> allHistory = await query
|
||||
.OrderBy(ph => ph.Id)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedPlayoutHistoryViewModel(totalCount, allHistory.Map(Mapper.ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetPlayoutWarningsCount : IRequest<int>;
|
||||
@@ -0,0 +1,15 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetPlayoutWarningsCountHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetPlayoutWarningsCount, int>
|
||||
{
|
||||
public async Task<int> Handle(GetPlayoutWarningsCount request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.PlayoutBuildStatus
|
||||
.CountAsync(bs => !bs.Success, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,43 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<SynchronizePlexCollections>,
|
||||
IRequestHandler<SynchronizePlexCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallPlexCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallPlexCollectionScannerHandler> logger) : base(
|
||||
dbContextFactory,
|
||||
configElementRepository,
|
||||
runtimeInfo,
|
||||
logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizePlexCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -40,7 +49,7 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizePlexCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -49,7 +58,7 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexMediaSourceId, cancellationToken)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -67,20 +76,40 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizePlexCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.PlexCollections);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-plex-collections", request.PlexMediaSourceId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-plex-collections",
|
||||
request.PlexMediaSourceId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
return BaseError.New("Plex collections are already scanning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
@@ -15,14 +17,17 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallPlexLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallPlexLibraryScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>.Handle(
|
||||
@@ -38,9 +43,9 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -53,38 +58,58 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.PlexLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-plex", request.PlexLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-plex",
|
||||
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return BaseError.New($"Library {request.PlexLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.PlexLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId, cancellationToken)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
Option<PlexLibrary> maybeLibrary = await dbContext.PlexLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId, cancellationToken);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
DateTime minDateTime = maybeLibrary.Match(
|
||||
l => l.LastScan ?? SystemTime.MinValueUtc,
|
||||
() => SystemTime.MaxValueUtc);
|
||||
|
||||
string libraryName = maybeLibrary.Match(l => l.Name, () => string.Empty);
|
||||
|
||||
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<SynchronizePlexNetworks>,
|
||||
IRequestHandler<SynchronizePlexNetworks, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallPlexNetworkScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallPlexNetworkScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizePlexNetworks request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
@@ -41,7 +47,7 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizePlexNetworks request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -51,7 +57,7 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId, cancellationToken)
|
||||
.Match(l => l.LastNetworksScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -69,20 +75,35 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizePlexNetworks request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.PlexNetworks);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-plex-networks", request.PlexLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-plex-networks",
|
||||
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
return BaseError.New("Plex networks are already scanning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizePlexShowById>,
|
||||
IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallPlexShowScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<CallPlexShowScannerHandler> logger)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>>.Handle(
|
||||
@@ -31,9 +35,9 @@ public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizeP
|
||||
SynchronizePlexShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -46,30 +50,44 @@ public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizeP
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizePlexShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.PlexLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
"scan-plex-show",
|
||||
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-plex-show",
|
||||
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return BaseError.New($"Library {request.PlexLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan(
|
||||
protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizePlexShowById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DateTimeOffset.MinValue);
|
||||
Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
|
||||
@@ -2,5 +2,5 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
: IRequest<Either<BaseError, Unit>>, IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -17,6 +17,8 @@ public record AddProgramScheduleItem(
|
||||
int? RerunCollectionId,
|
||||
int? MediaItemId,
|
||||
int? PlaylistId,
|
||||
string SearchTitle,
|
||||
string SearchQuery,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
MarathonGroupBy MarathonGroupBy,
|
||||
bool MarathonShuffleGroups,
|
||||
@@ -57,6 +59,8 @@ public record AddProgramScheduleItem(
|
||||
RerunCollectionId: null,
|
||||
MediaItemId: mediaItemId,
|
||||
PlaylistId: null,
|
||||
SearchTitle: null,
|
||||
SearchQuery: null,
|
||||
PlaybackOrder.Shuffle,
|
||||
MarathonGroupBy.None,
|
||||
MarathonShuffleGroups: false,
|
||||
|
||||
@@ -14,6 +14,8 @@ public interface IProgramScheduleItemRequest
|
||||
int? RerunCollectionId { get; }
|
||||
int? MediaItemId { get; }
|
||||
int? PlaylistId { get; }
|
||||
string SearchTitle { get; }
|
||||
string SearchQuery { get; }
|
||||
PlayoutMode PlayoutMode { get; }
|
||||
PlaybackOrder PlaybackOrder { get; }
|
||||
MarathonGroupBy MarathonGroupBy { get; }
|
||||
|
||||
@@ -188,6 +188,13 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
return BaseError.New("[Playlist] is required for collection type 'Playlist'");
|
||||
}
|
||||
|
||||
break;
|
||||
case CollectionType.SearchQuery:
|
||||
if (string.IsNullOrWhiteSpace(item.SearchQuery))
|
||||
{
|
||||
return BaseError.New("[SearchQuery] is required for collection type 'SearchQuery'");
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
return BaseError.New("[CollectionType] is invalid");
|
||||
@@ -216,6 +223,8 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
RerunCollectionId = item.RerunCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaylistId = item.PlaylistId,
|
||||
SearchTitle = item.SearchTitle,
|
||||
SearchQuery = item.SearchQuery,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
MarathonGroupBy = item.MarathonGroupBy,
|
||||
MarathonShuffleGroups = item.MarathonShuffleGroups,
|
||||
@@ -247,6 +256,8 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
RerunCollectionId = item.RerunCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaylistId = item.PlaylistId,
|
||||
SearchTitle = item.SearchTitle,
|
||||
SearchQuery = item.SearchQuery,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
MarathonGroupBy = item.MarathonGroupBy,
|
||||
MarathonShuffleGroups = item.MarathonShuffleGroups,
|
||||
@@ -278,6 +289,8 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
RerunCollectionId = item.RerunCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaylistId = item.PlaylistId,
|
||||
SearchTitle = item.SearchTitle,
|
||||
SearchQuery = item.SearchQuery,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
MarathonGroupBy = item.MarathonGroupBy,
|
||||
MarathonShuffleGroups = item.MarathonShuffleGroups,
|
||||
@@ -311,6 +324,8 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
RerunCollectionId = item.RerunCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaylistId = item.PlaylistId,
|
||||
SearchTitle = item.SearchTitle,
|
||||
SearchQuery = item.SearchQuery,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
MarathonGroupBy = item.MarathonGroupBy,
|
||||
MarathonShuffleGroups = item.MarathonShuffleGroups,
|
||||
|
||||
@@ -17,6 +17,8 @@ public record ReplaceProgramScheduleItem(
|
||||
int? RerunCollectionId,
|
||||
int? MediaItemId,
|
||||
int? PlaylistId,
|
||||
string SearchTitle,
|
||||
string SearchQuery,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
MarathonGroupBy MarathonGroupBy,
|
||||
bool MarathonShuffleGroups,
|
||||
|
||||
@@ -9,25 +9,16 @@ using static ErsatzTV.Application.ProgramSchedules.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase,
|
||||
public class ReplaceProgramScheduleItemsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel) : ProgramScheduleItemCommandBase,
|
||||
IRequestHandler<ReplaceProgramScheduleItems, Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public ReplaceProgramScheduleItemsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>> Handle(
|
||||
ReplaceProgramScheduleItems request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken));
|
||||
}
|
||||
@@ -53,7 +44,7 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
|
||||
// refresh any playouts that use this schedule
|
||||
foreach (Playout playout in programSchedule.Playouts)
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
}
|
||||
|
||||
return programSchedule.Items.Map(ProjectToViewModel);
|
||||
@@ -121,7 +112,8 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
|
||||
item.MultiCollectionId,
|
||||
item.SmartCollectionId,
|
||||
item.RerunCollectionId,
|
||||
item.PlaylistId);
|
||||
item.PlaylistId,
|
||||
item.SearchQuery);
|
||||
|
||||
if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet<PlaybackOrder> playbackOrders))
|
||||
{
|
||||
@@ -147,5 +139,6 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? RerunCollectionId,
|
||||
int? PlaylistId);
|
||||
int? PlaylistId,
|
||||
string SearchQuery);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ internal static class Mapper
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
duration.SearchTitle,
|
||||
duration.SearchQuery,
|
||||
duration.PlaybackOrder,
|
||||
duration.MarathonGroupBy,
|
||||
duration.MarathonShuffleGroups,
|
||||
@@ -111,6 +113,8 @@ internal static class Mapper
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
flood.SearchTitle,
|
||||
flood.SearchQuery,
|
||||
flood.PlaybackOrder,
|
||||
flood.MarathonGroupBy,
|
||||
flood.MarathonShuffleGroups,
|
||||
@@ -172,6 +176,8 @@ internal static class Mapper
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
multiple.SearchTitle,
|
||||
multiple.SearchQuery,
|
||||
multiple.PlaybackOrder,
|
||||
multiple.MarathonGroupBy,
|
||||
multiple.MarathonShuffleGroups,
|
||||
@@ -235,6 +241,8 @@ internal static class Mapper
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
one.SearchTitle,
|
||||
one.SearchQuery,
|
||||
one.PlaybackOrder,
|
||||
one.MarathonGroupBy,
|
||||
one.MarathonShuffleGroups,
|
||||
|
||||
@@ -23,6 +23,8 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
|
||||
RerunCollectionViewModel rerunCollection,
|
||||
PlaylistViewModel playlist,
|
||||
NamedMediaItemViewModel mediaItem,
|
||||
string searchTitle,
|
||||
string searchQuery,
|
||||
PlaybackOrder playbackOrder,
|
||||
MarathonGroupBy marathonGroupBy,
|
||||
bool marathonShuffleGroups,
|
||||
@@ -58,6 +60,8 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
|
||||
rerunCollection,
|
||||
playlist,
|
||||
mediaItem,
|
||||
searchTitle,
|
||||
searchQuery,
|
||||
playbackOrder,
|
||||
marathonGroupBy,
|
||||
marathonShuffleGroups,
|
||||
|
||||
@@ -23,6 +23,8 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
|
||||
RerunCollectionViewModel rerunCollection,
|
||||
PlaylistViewModel playlist,
|
||||
NamedMediaItemViewModel mediaItem,
|
||||
string searchTitle,
|
||||
string searchQuery,
|
||||
PlaybackOrder playbackOrder,
|
||||
MarathonGroupBy marathonGroupBy,
|
||||
bool marathonShuffleGroups,
|
||||
@@ -55,6 +57,8 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
|
||||
rerunCollection,
|
||||
playlist,
|
||||
mediaItem,
|
||||
searchTitle,
|
||||
searchQuery,
|
||||
playbackOrder,
|
||||
marathonGroupBy,
|
||||
marathonShuffleGroups,
|
||||
|
||||
@@ -23,6 +23,8 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
|
||||
RerunCollectionViewModel rerunCollection,
|
||||
PlaylistViewModel playlist,
|
||||
NamedMediaItemViewModel mediaItem,
|
||||
string searchTitle,
|
||||
string searchQuery,
|
||||
PlaybackOrder playbackOrder,
|
||||
MarathonGroupBy marathonGroupBy,
|
||||
bool marathonShuffleGroups,
|
||||
@@ -57,6 +59,8 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
|
||||
rerunCollection,
|
||||
playlist,
|
||||
mediaItem,
|
||||
searchTitle,
|
||||
searchQuery,
|
||||
playbackOrder,
|
||||
marathonGroupBy,
|
||||
marathonShuffleGroups,
|
||||
|
||||
@@ -23,6 +23,8 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
|
||||
RerunCollectionViewModel rerunCollection,
|
||||
PlaylistViewModel playlist,
|
||||
NamedMediaItemViewModel mediaItem,
|
||||
string searchTitle,
|
||||
string searchQuery,
|
||||
PlaybackOrder playbackOrder,
|
||||
MarathonGroupBy marathonGroupBy,
|
||||
bool marathonShuffleGroups,
|
||||
@@ -55,6 +57,8 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
|
||||
rerunCollection,
|
||||
playlist,
|
||||
mediaItem,
|
||||
searchTitle,
|
||||
searchQuery,
|
||||
playbackOrder,
|
||||
marathonGroupBy,
|
||||
marathonShuffleGroups,
|
||||
|
||||
@@ -22,6 +22,8 @@ public abstract record ProgramScheduleItemViewModel(
|
||||
RerunCollectionViewModel RerunCollection,
|
||||
PlaylistViewModel Playlist,
|
||||
NamedMediaItemViewModel MediaItem,
|
||||
string SearchTitle,
|
||||
string SearchQuery,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
MarathonGroupBy MarathonGroupBy,
|
||||
bool MarathonShuffleGroups,
|
||||
@@ -55,6 +57,8 @@ public abstract record ProgramScheduleItemViewModel(
|
||||
MultiCollection?.Name,
|
||||
CollectionType.SmartCollection =>
|
||||
SmartCollection?.Name,
|
||||
CollectionType.SearchQuery =>
|
||||
string.IsNullOrWhiteSpace(SearchTitle) ? SearchQuery : SearchTitle,
|
||||
CollectionType.Playlist =>
|
||||
Playlist?.Name,
|
||||
CollectionType.RerunFirstRun or CollectionType.RerunRerun =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user