Compare commits

...

55 Commits

Author SHA1 Message Date
Jason Dove 474e647d6d
more jellyfin performance improvements (#2747) 18 hours ago
Jon Crall daff1c6533
Add select all controls to media lists (#2738) 1 day ago
Jason Dove 14d2dd0c3a
optimize jellyfin database fields and indexes (#2746) 1 day ago
Jason Dove c606319030
add some performance troubleshooting env vars (#2745) 1 day ago
Jason Dove 1b72b8491c
improve multi-episode grouping logic (#2744) 1 day ago
Jason Dove 9fea25a77d
allow string values for count instruction in sequential schedules (#2741) 2 days ago
Jason Dove 74b049b6e3
fix nvenc playback when color metadata changes mid-stream (#2740) 2 days ago
Jason Dove b2caf8ee8d
fix remote stream indexing due to missing titles (#2739) 2 days ago
Jason Dove b582b4cbf7
fix downgrade health check failure for mariadb (#2737) 1 week ago
Jason Dove 0af81ad839
add target loudness to ffmpeg profile (#2727) 2 weeks ago
Jason Dove 2f0cd1eb6c
update dependencies (#2726) 2 weeks ago
Jason Dove e4f1a93db0
fix some mysql migrations that failed on mariadb (#2725) 2 weeks ago
Jason Dove 6562d616fb
smart collection names must be case insensitive (#2721) 3 weeks ago
Jason Dove d8122edad6
fix duplicate smart collection names (#2720) 3 weeks ago
Jason Dove 99b8c56a31
rework fallback filler (#2719) 3 weeks ago
Jason Dove 09858df654
fix case when cuda hw decode falls back to sw (#2718) 3 weeks ago
Jason Dove 038286c92b
use playlist item count when playlists are used as filler (#2716) 3 weeks ago
Jason Dove 8575ab5c32
fix bt2020 playback (#2714) 3 weeks ago
Jason Dove 8b768a2990
allow playlists to have no items included in epg (#2713) 3 weeks ago
Jason Dove f9e4c4d386
improve build time by only running analyzers explicitly (#2710) 3 weeks ago
Jason Dove a1f9b86fc1
add download media sample button to playback troubleshooting (#2709) 3 weeks ago
Jason Dove 5dc20ebd1b
use software pad with amd vaapi h264 main (#2708) 4 weeks ago
Jason Dove d30e8b4102
only use packed headers with vaapi when supported by encoder (#2706) 4 weeks ago
Jason Dove c14f373f23
implement rectangles packet for script element (#2704) 4 weeks ago
Jason Dove a90fe26d50
script element packet spike (#2703) 4 weeks ago
Jason Dove 7a263ddaed
add migration to fix any incorrect channel sort numbers (#2701) 4 weeks ago
Jason Dove 3e0a9aae1e
fix channel sort number when reordering channels (#2700) 4 weeks ago
Jason Dove 72dc401829
fix chronological sorting for other videos (#2699) 4 weeks ago
Jason Dove 85e25ca6ea
add channel start time template data (#2698) 4 weeks ago
Jason Dove 9c23b03758
fix mirror channels (#2697) 4 weeks ago
Jason Dove e12888ebee
fix recent regression to subtitle graphics element (#2696) 4 weeks ago
Jason Dove 468ff087d4
fix loading epg entries for motion and script elements (#2693) 1 month ago
Jason Dove 54606c76f9
framerate improvements (#2692) 1 month ago
Jason Dove 6bd49ffcec
add remote stream metadata (#2690) 1 month ago
Jason Dove c524bc0d7d
add script graphics element (#2681) 1 month ago
Jason Dove 42bcadf936
work around buggy radeonsi hevc_vaapi behavior (#2680) 1 month ago
Jason Dove 1f31beab5b
fix plex other video library detection (#2679) 1 month ago
Jason Dove b45c22092d
fix startup on systems unsupported by nvencsharp (#2678) 1 month ago
Jason Dove 7bd8cefe2e
more dotnet 10 updates (#2676) 1 month ago
Jason Dove f101d0b366
prep for release v25.9.0 [no ci] 1 month ago
Jason Dove 73aabdabda
fix transcoding tests (#2675) [no ci] 1 month ago
Jason Dove bcea96d53a
always log scanner exit code when it is non-zero (#2670) 1 month ago
Jason Dove d7952e4cfa
fix docker assets (#2669) 1 month ago
Jason Dove 758399e339
fix missing net9.0 to net10.0 in docker/github (#2668) 1 month ago
Jason Dove 6c635a4be9
upgrade to dotnet 10 (#2667) 1 month ago
Jason Dove 9d637cdd54
update dependencies (#2661) 1 month ago
Jason Dove cc287ffc6e
fix hls direct streams remaining open (#2660) 1 month ago
Jason Dove 371659c5c5
cache bust new logo (#2659) 1 month ago
Jason Dove 7afb1866ad
update logo (#2658) 1 month ago
Jason Dove 7bc1dd63fe
fix file system test on windows (#2657) [no ci] 1 month ago
Jason Dove 076b8a7188
fix editing certain playouts when using mysql (#2656) 1 month ago
Jason Dove ec0d8ea6ac
work around sequential schedule validation limit (#2655) 1 month ago
Jason Dove e40d192aea
limit hw sw decode downgrade polaris (#2654) 1 month ago
Jason Dove bd7fd8984c
fix 10-bit decoding with amd polaris (#2653) 1 month ago
Jason Dove 2682912f5a
update icon (#2652) 1 month ago
  1. 4
      .config/dotnet-tools.json
  2. 18
      .github/workflows/artifacts.yml
  3. 31
      .github/workflows/pr.yml
  4. 93
      CHANGELOG.md
  5. 3
      Directory.Build.props
  6. 15
      Directory.Build.targets
  7. BIN
      ErsatzTV-Windows/Ersatztv.ico
  8. 10
      ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs
  9. 117
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  10. 8
      ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs
  11. 9
      ErsatzTV.Application/Channels/Commands/UpdateChannelNumbersHandler.cs
  12. 6
      ErsatzTV.Application/Channels/Queries/GetChannelFramerate.cs
  13. 83
      ErsatzTV.Application/Channels/Queries/GetChannelFramerateHandler.cs
  14. 6
      ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs
  15. 8
      ErsatzTV.Application/Emby/Commands/CallEmbyCollectionScannerHandler.cs
  16. 6
      ErsatzTV.Application/Emby/Commands/CallEmbyLibraryScannerHandler.cs
  17. 6
      ErsatzTV.Application/Emby/Commands/CallEmbyShowScannerHandler.cs
  18. 16
      ErsatzTV.Application/ErsatzTV.Application.csproj
  19. 1
      ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs
  20. 5
      ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs
  21. 1
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs
  22. 5
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs
  23. 6
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  24. 1
      ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs
  25. 1
      ErsatzTV.Application/FFmpegProfiles/Mapper.cs
  26. 10
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs
  27. 24
      ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs
  28. 1
      ErsatzTV.Application/Graphics/Mapper.cs
  29. 8
      ErsatzTV.Application/Jellyfin/Commands/CallJellyfinCollectionScannerHandler.cs
  30. 6
      ErsatzTV.Application/Jellyfin/Commands/CallJellyfinLibraryScannerHandler.cs
  31. 6
      ErsatzTV.Application/Jellyfin/Commands/CallJellyfinShowScannerHandler.cs
  32. 8
      ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs
  33. 2
      ErsatzTV.Application/MediaCards/Mapper.cs
  34. 2
      ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs
  35. 26
      ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs
  36. 8
      ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItemsHandler.cs
  37. 6
      ErsatzTV.Application/MediaSources/Commands/CallLocalLibraryScannerHandler.cs
  38. 10
      ErsatzTV.Application/Playouts/Commands/CreateExternalJsonPlayoutHandler.cs
  39. 30
      ErsatzTV.Application/Playouts/Commands/CreateScriptedPlayoutHandler.cs
  40. 31
      ErsatzTV.Application/Playouts/Commands/CreateSequentialPlayoutHandler.cs
  41. 36
      ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs
  42. 6
      ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs
  43. 8
      ErsatzTV.Application/Plex/Commands/CallPlexCollectionScannerHandler.cs
  44. 6
      ErsatzTV.Application/Plex/Commands/CallPlexLibraryScannerHandler.cs
  45. 5
      ErsatzTV.Application/Plex/Commands/CallPlexNetworkScannerHandler.cs
  46. 6
      ErsatzTV.Application/Plex/Commands/CallPlexShowScannerHandler.cs
  47. 12
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  48. 36
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  49. 59
      ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs
  50. 4
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs
  51. 10
      ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs
  52. 8
      ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs
  53. 3
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs
  54. 131
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  55. 4
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs
  56. 6
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitlesHandler.cs
  57. 8
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
  58. 12
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs
  59. 3
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSample.cs
  60. 169
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSampleHandler.cs
  61. 199
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  62. 3
      ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs
  63. 167
      ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs
  64. 6
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentAll.cs
  65. 5
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentCollection.cs
  66. 4
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentCreatePlaylist.cs
  67. 4
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentMarathon.cs
  68. 4
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentMultiCollection.cs
  69. 6
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentPlaylist.cs
  70. 15
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentSearch.cs
  71. 4
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentShow.cs
  72. 4
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentSmartCollection.cs
  73. 0
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlGraphicsOff.cs
  74. 2
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlGraphicsOn.cs
  75. 2
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlPreRollOn.cs
  76. 4
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlSkipItems.cs
  77. 6
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlSkipToItem.cs
  78. 2
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlStartEpgGroup.cs
  79. 2
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlWaitUntil.cs
  80. 2
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlWaitUntilExact.cs
  81. 0
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlWatermarkOff.cs
  82. 2
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlWatermarkOn.cs
  83. 4
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PeekItemDuration.cs
  84. 4
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlaylistItem.cs
  85. 8
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutContext.cs
  86. 8
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutCount.cs
  87. 10
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutDuration.cs
  88. 10
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutPadToNext.cs
  89. 10
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutPadUntil.cs
  90. 10
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutPadUntilExact.cs
  91. 14
      ErsatzTV.Core.Nullable/ErsatzTV.Core.Nullable.csproj
  92. 25
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  93. 177
      ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs
  94. 7
      ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs
  95. 8
      ErsatzTV.Core.Tests/Fakes/FakeFileEntry.cs
  96. 3
      ErsatzTV.Core.Tests/Fakes/FakeFolderEntry.cs
  97. 99
      ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs
  98. 48
      ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs
  99. 11
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs
  100. 35
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs
  101. Some files were not shown because too many files have changed in this diff Show More

4
.config/dotnet-tools.json

@ -3,11 +3,11 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"jetbrains.resharper.globaltools": { "jetbrains.resharper.globaltools": {
"version": "2025.3.0", "version": "2025.3.0.2",
"commands": [ "commands": [
"jb" "jb"
], ],
"rollForward": false "rollForward": false
} }
} }
} }

18
.github/workflows/artifacts.yml

@ -48,7 +48,7 @@ jobs:
- name: Setup dotnet - name: Setup dotnet
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 9.0.203 dotnet-version: '10.0.x'
- name: Clean - name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@ -72,8 +72,8 @@ jobs:
shell: bash shell: bash
run: | run: |
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj 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.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 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 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 - name: Bundle
shell: bash shell: bash
@ -163,7 +163,7 @@ jobs:
- name: Setup dotnet - name: Setup dotnet
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 9.0.203 dotnet-version: '10.0.x'
- name: Clean - name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@ -180,8 +180,8 @@ jobs:
# Build everything # Build everything
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj 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.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 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/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" mkdir "$release_name"
mv scanner/* "$release_name/" mv scanner/* "$release_name/"
mv main/* "$release_name/" mv main/* "$release_name/"
@ -220,7 +220,7 @@ jobs:
- name: Setup dotnet - name: Setup dotnet
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 9.0.203 dotnet-version: '10.0.x'
- name: Clean - name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@ -232,8 +232,8 @@ jobs:
shell: bash shell: bash
run: | run: |
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj 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.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 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/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 - name: Upload .NET Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

31
.github/workflows/pr.yml

@ -2,6 +2,31 @@
on: on:
pull_request: pull_request:
jobs: jobs:
build_and_analyze:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore /p:EnableThreadingAnalyzers=true
build_and_test_windows: build_and_test_windows:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
@ -11,7 +36,7 @@ jobs:
- name: Setup dotnet - name: Setup dotnet
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 9.0.203 dotnet-version: '10.0.x'
- name: Clean - name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@ -52,7 +77,7 @@ jobs:
- name: Setup dotnet - name: Setup dotnet
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 9.0.203 dotnet-version: '10.0.x'
- name: Clean - name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@ -80,7 +105,7 @@ jobs:
- name: Setup dotnet - name: Setup dotnet
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 9.0.203 dotnet-version: '10.0.x'
- name: Clean - name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear run: dotnet clean --configuration Release && dotnet nuget locals all --clear

93
CHANGELOG.md

@ -5,6 +5,88 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- Graphics Engine:
- Add `script` graphics element type
- Supported in playback troubleshooting and all scheduling types
- Supports arbitrary scripts or executables that output graphics to ETV via stdout
- Supports EPG and Media Item replacement in entire template
- EPG data is sourced from XMLTV for the current time
- EPG data can also load a configurable number of subsequent (up next) entries
- Media Item data is sourced from the currently playing media item
- All template data will also be passed as JSON to the stdin stream of the command
- Template supports:
- Script and arguments (`command` and `args`)
- Draw order (`z_index`)
- Timing (`start_seconds` and `duration_seconds`)
- Data format (`format`)
- `raw` format means full frames of BGRA data to stdout
- `packet` format means ETV graphics packets to stdout
- Add framerate template data
- `RFrameRate` - the real content framerate (or channel normalized framerate) as reported by ffmpeg, e.g. `30000/1001`
- `FrameRate` - the decimal representation of `RFrameRate`, e.g. `29.97002997`
- Add `Channel_StartTime` template data
- This indicates the time that the transcode session started for the current channel
- Add remote stream metadata
- Remote stream definitions (yaml files) can now contain `title`, `plot`, `year` and `content_rating` fields
- Remote streams can now have thumbnails (same name as yaml file but with image extension)
- This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds
- Add `Download Media Sample` button to playback troubleshooting
- This button will extract up to 30 seconds of the media item and zip it
- Add `Target Loudness` (LUFS/LKFS) to ffmpeg profile when loudness normalization is enabled
- Default value is `-16`; some sources normalize to a quieter value, e.g. `-24`
- Add environment variables to help troubleshoot performance
- `ETV_SLOW_DB_MS` - milliseconds threshold for logging slow database queries (at DEBUG level)
- e.g. if this is set to `1000`, queries taking longer than 1 second will be logged
- `ETV_SLOW_API_MS` - milliseconds threshold for logging slow API calls (at DEBUG level)
- This is currently limited to *Jellyfin*
- `ETV_JF_PAGE_SIZE` - page size for library scan API calls to Jellyfin; default value is 10
- Add `Select All` button to media pages by @Erotemic
### Fixed
- Fix startup on systems unsupported by NvEncSharp
- Fix detection of Plex Other Video libraries using `Plex Personal Media` agent
- If the library is already detected as a Movies library in ETV, synchronization must be disabled for the library to change it to an Other Videos library
- A warning will be logged when this scenario is detected
- Graphics Engine:
- Optimize graphics engine to generate element frames in parallel and to eliminate redundant frame copies
- Match graphics engine framerate with source content (or channel normalized) framerate
- Fix loading requested number of epg entries for motion graphics elements
- Fix bug with mirror channels where seemingly random content would be played every ~40 seconds
- Fix chronological sorting for Other Videos that have release date metadata
- Fix playout sorting after using channel number editor
- VAAPI: Only include `-sei a53_cc` flags when misc packed headers are supported by the encoder
- This should fix playback in some cases, e.g. AMD VAAPI h264 encoder
- AMD VAAPI:
- work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080
- fix green padding when encoding h264 using main profile
- Automatically kill playback troubleshooting ffmpeg process if it hasn't completed after two minutes
- Fix playback of certain BT.2020 content
- Use playlist item count when using a playlist as filler (instead of a fixed count of 1 for each playlist item)
- NVIDIA:
- Fix stream failure with certain content that should decode in hardware but falls back to software
- Fix stream failure with content that changes color metadata mid-stream
- Fix stream failure when configured fallback filler collection is empty
- Fix high CPU when errors are displayed; errors will now work ahead before throttling to realtime, similar to primary content
- Fix startup error caused by duplicate smart collection names (and no longer allow duplicate smart collection names)
- Fix erroneous downgrade health check failure with some installations that use MariaDB
- Sequential schedules: fix `count` instruction validation to accept integer (constant) or string (expression)
- Fix multi-part episode grouping logic so that it does NOT require release date metadata for episodes within a single show
- When **Treat Collections As Shows** is enabled (i.e. for crossover episodes) release date metadata is required for proper grouping
### Changed
- No longer round framerate to nearest integer when normalizing framerate
- Allow playlists to have no items included in EPG
- Change how fallback filler works
- Items will no longer loop; instead, a sequence of random items will be selected from the collection
- Items may still be cut as needed
- Hardware acceleration will now be used
- Items can "work ahead" (transcode faster than realtime) when less than 3 minutes in duration
- Optimize Jellyfin database fields and indexes
- Optimize Jellyfin show library scans by only requesting `People` (actors, directors, writers) when etags don't match
- This should significantly speed up periodic library scans, particularly against Jellyfin 10.11.x
## [25.9.0] - 2025-11-29
### Added
- Show playout warnings count badge in left menu - Show playout warnings count badge in left menu
- Graphics Engine: - Graphics Engine:
- Add `MediaItem_Resolution` template data (the current `Resolution` variable is the FFmpeg Profile resolution) - Add `MediaItem_Resolution` template data (the current `Resolution` variable is the FFmpeg Profile resolution)
@ -83,6 +165,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add toggle to hide/show disabled channels in channel list - 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 - 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 - 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 ### Changed
- Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them - Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them
@ -97,6 +186,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Template groups in template list - Template groups in template list
- Block groups and blocks in template editor - Block groups and blocks in template editor
- Replace template tree view with searchable table (like blocks) - Replace template tree view with searchable table (like blocks)
- Upgrade to dotnet 10
## [25.8.0] - 2025-10-26 ## [25.8.0] - 2025-10-26
### Added ### Added
@ -2981,7 +3071,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker. - Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.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.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.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.7.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.6.0...v25.7.0

3
Directory.Build.props

@ -2,5 +2,6 @@
<PropertyGroup> <PropertyGroup>
<InformationalVersion>develop</InformationalVersion> <InformationalVersion>develop</InformationalVersion>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion> <IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

15
Directory.Build.targets

@ -0,0 +1,15 @@
<Project>
<PropertyGroup>
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.VisualStudio.Threading.Analyzers"
Version="17.14.15"
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

BIN
ErsatzTV-Windows/Ersatztv.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

10
ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs

@ -1,6 +1,6 @@
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -12,19 +12,19 @@ namespace ErsatzTV.Application.Channels;
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>> public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem; private readonly IFileSystem _fileSystem;
private readonly ISearchTargets _searchTargets; private readonly ISearchTargets _searchTargets;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public DeleteChannelHandler( public DeleteChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ISearchTargets searchTargets) ISearchTargets searchTargets)
{ {
_workerChannel = workerChannel; _workerChannel = workerChannel;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_searchTargets = searchTargets; _searchTargets = searchTargets;
} }
@ -44,7 +44,7 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
// delete channel data from channel guide cache // delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml"); string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (_localFileSystem.FileExists(cacheFile)) if (_fileSystem.File.Exists(cacheFile))
{ {
File.Delete(cacheFile); File.Delete(cacheFile);
} }

117
ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs

@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using System.Xml; using System.Xml;
using ErsatzTV.Application.Configuration; using ErsatzTV.Application.Configuration;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -26,6 +27,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelDataHandler> _logger; private readonly ILogger<RefreshChannelDataHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
@ -33,12 +35,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
public RefreshChannelDataHandler( public RefreshChannelDataHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager, RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILogger<RefreshChannelDataHandler> logger) ILogger<RefreshChannelDataHandler> logger)
{ {
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; _recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_logger = logger; _logger = logger;
@ -69,9 +73,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName(); string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName(); string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName(); string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
string remoteStreamTemplateFileName = GetRemoteStreamTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null || if (movieTemplateFileName is null || episodeTemplateFileName is null ||
musicVideoTemplateFileName is null || musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null) songTemplateFileName is null || otherVideoTemplateFileName is null ||
remoteStreamTemplateFileName is null)
{ {
return; return;
} }
@ -101,6 +107,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken); string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName); var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
string remoteStreamText = await File.ReadAllTextAsync(remoteStreamTemplateFileName, cancellationToken);
var remoteStreamTemplate = Template.Parse(remoteStreamText, remoteStreamTemplateFileName);
TimeSpan playoutOffset = TimeSpan.Zero; TimeSpan playoutOffset = TimeSpan.Zero;
string mirrorChannelNumber = null; string mirrorChannelNumber = null;
Option<Channel> maybeChannel = await dbContext.Channels Option<Channel> maybeChannel = await dbContext.Channels
@ -189,6 +198,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(vm => vm.Artwork) .ThenInclude(vm => vm.Artwork)
.Include(p => p.Items) .Include(p => p.Items)
.ThenInclude(i => i.MediaItem) .ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata) .ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork) .ThenInclude(vm => vm.Artwork)
.Include(p => p.Items) .Include(p => p.Items)
@ -199,6 +212,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(i => i.MediaItem) .ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata) .ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios) .ThenInclude(sm => sm.Studios)
.AsSplitQuery()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream(); await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
@ -239,6 +253,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml, xml,
cancellationToken); cancellationToken);
@ -264,6 +279,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml, xml,
cancellationToken); cancellationToken);
@ -287,6 +303,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml, xml,
cancellationToken); cancellationToken);
@ -316,6 +333,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate, Template musicVideoTemplate,
Template songTemplate, Template songTemplate,
Template otherVideoTemplate, Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier, XmlMinifier minifier,
XmlWriter xml, XmlWriter xml,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -390,6 +408,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml); xml);
@ -406,6 +425,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate, Template musicVideoTemplate,
Template songTemplate, Template songTemplate,
Template otherVideoTemplate, Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier, XmlMinifier minifier,
XmlWriter xml, XmlWriter xml,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -461,6 +481,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml); xml);
} }
@ -500,6 +521,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml); xml);
@ -523,6 +545,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate, Template musicVideoTemplate,
Template songTemplate, Template songTemplate,
Template otherVideoTemplate, Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier, XmlMinifier minifier,
XmlWriter xml) XmlWriter xml)
{ {
@ -584,6 +607,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
title, title,
templateContext, templateContext,
otherVideoTemplate), otherVideoTemplate),
RemoteStream templateRemoteStream => await ProcessRemoteStreamTemplate(
request,
templateRemoteStream,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
remoteStreamTemplate),
_ => Option<string>.None _ => Option<string>.None
}; };
@ -879,6 +912,55 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return Option<string>.None; return Option<string>.None;
} }
private static async Task<Option<string>> ProcessRemoteStreamTemplate(
RefreshChannelData request,
RemoteStream templateRemoteStream,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
XmlTemplateContext templateContext,
Template remoteStreamTemplate)
{
foreach (RemoteStreamMetadata metadata in templateRemoteStream.RemoteStreamMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
RemoteStreamTitle = title,
RemoteStreamHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
RemoteStreamPlot = metadata.Plot,
RemoteStreamHasYear = metadata.Year.HasValue,
RemoteStreamYear = metadata.Year,
RemoteStreamHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
RemoteStreamArtworkUrl = artworkPath,
RemoteStreamGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
RemoteStreamHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
RemoteStreamContentRating = metadata.ContentRating
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await remoteStreamTemplate.RenderAsync(templateContext);
}
return Option<string>.None;
}
private string GetMovieTemplateFileName() private string GetMovieTemplateFileName()
{ {
string templateFileName = _localFileSystem.GetCustomOrDefaultFile( string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
@ -886,7 +968,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
"movie.sbntxt"); "movie.sbntxt");
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV", "Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV",
@ -905,7 +987,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
"episode.sbntxt"); "episode.sbntxt");
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV", "Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV",
@ -924,7 +1006,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
"musicVideo.sbntxt"); "musicVideo.sbntxt");
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV", "Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV",
@ -943,7 +1025,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
"song.sbntxt"); "song.sbntxt");
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV", "Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV",
@ -962,7 +1044,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
"otherVideo.sbntxt"); "otherVideo.sbntxt");
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV", "Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV",
@ -974,6 +1056,25 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return templateFileName; return templateFileName;
} }
private string GetRemoteStreamTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"remoteStream.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate remote stream XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind) private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
{ {
string artworkPath = artwork.Path; string artworkPath = artwork.Path;
@ -1029,6 +1130,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.IfNone("[unknown artist]"), .IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"), .IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]" _ => "[unknown]"
}; };
} }
@ -1077,7 +1180,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{ {
var result = new List<PlayoutItem>(); var result = new List<PlayoutItem>();
if (_localFileSystem.FileExists(path)) if (_fileSystem.File.Exists(path))
{ {
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>( Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
await File.ReadAllTextAsync(path)); await File.ReadAllTextAsync(path));

8
ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs

@ -1,4 +1,5 @@
using System.Data.Common; using System.Data.Common;
using System.IO.Abstractions;
using System.Net; using System.Net;
using System.Xml; using System.Xml;
using Dapper; using Dapper;
@ -19,6 +20,7 @@ namespace ErsatzTV.Application.Channels;
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList> public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelListHandler> _logger; private readonly ILogger<RefreshChannelListHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
@ -26,11 +28,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
public RefreshChannelListHandler( public RefreshChannelListHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager, RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<RefreshChannelListHandler> logger) ILogger<RefreshChannelListHandler> logger)
{ {
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; _recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_logger = logger; _logger = logger;
} }
@ -44,13 +48,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt"); string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
// fall back to default template // fall back to default template
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt"); templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
} }
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate channel list without template file {File}; please restart ErsatzTV", "Unable to generate channel list without template file {File}; please restart ErsatzTV",

9
ErsatzTV.Application/Channels/Commands/UpdateChannelNumbersHandler.cs

@ -1,3 +1,4 @@
using System.Globalization;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@ -38,6 +39,14 @@ public class UpdateChannelNumbersHandler(
foreach (var channel in channelsToUpdate) foreach (var channel in channelsToUpdate)
{ {
channel.Number = numberUpdates[channel.Id]; channel.Number = numberUpdates[channel.Id];
if (double.TryParse(channel.Number, CultureInfo.InvariantCulture, out double sortNumber))
{
channel.SortNumber = sortNumber;
}
else
{
return BaseError.New($"Failed to parse channel number {channel.Number}");
}
} }
// save those changes // save those changes

6
ErsatzTV.Application/Channels/Queries/GetChannelFramerate.cs

@ -1,3 +1,5 @@
namespace ErsatzTV.Application.Channels; using ErsatzTV.FFmpeg;
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<int>>; namespace ErsatzTV.Application.Channels;
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<FrameRate>>;

83
ErsatzTV.Application/Channels/Queries/GetChannelFramerateHandler.cs

@ -1,29 +1,22 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, Option<int>> public class GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
: IRequestHandler<GetChannelFramerate, Option<FrameRate>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; public async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
private readonly ILogger<GetChannelFramerateHandler> _logger;
public GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
{ {
try try
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
FFmpegProfile ffmpegProfile = await dbContext.Channels FFmpegProfile ffmpegProfile = await dbContext.Channels
.AsNoTracking() .AsNoTracking()
@ -34,11 +27,11 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
if (!ffmpegProfile.NormalizeFramerate) if (!ffmpegProfile.NormalizeFramerate)
{ {
return Option<int>.None; return Option<FrameRate>.None;
} }
// TODO: expand to check everything in collection rather than what's scheduled? // TODO: expand to check everything in collection rather than what's scheduled?
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber); logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
List<Playout> playouts = await dbContext.Playouts List<Playout> playouts = await dbContext.Playouts
.AsNoTracking() .AsNoTracking()
@ -68,51 +61,53 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion())) var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
.Flatten() .Flatten()
.Map(mv => mv.RFrameRate) .Map(mv => new FrameRate(mv.RFrameRate))
.ToList(); .ToList();
var distinct = frameRates.Distinct().ToList(); var distinct = frameRates.Distinct().ToList();
if (distinct.Count > 1) if (distinct.Count > 1)
{ {
// TODO: something more intelligent than minimum framerate? // TODO: something more intelligent than minimum framerate?
int result = frameRates.Map(ParseFrameRate).Min(); var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList();
if (result < 24) if (validFrameRates.Count > 0)
{ {
_logger.LogInformation( FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate);
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}", logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
request.ChannelNumber, request.ChannelNumber,
distinct, distinct.Map(fr => fr.RFrameRate),
24, result.RFrameRate);
result); return result;
return 24;
} }
_logger.LogInformation( FrameRate minFrameRate = frameRates.MinBy(fr => fr.ParsedFrameRate);
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}", logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
request.ChannelNumber, request.ChannelNumber,
distinct, distinct.Map(fr => fr.RFrameRate),
result); FrameRate.DefaultFrameRate.RFrameRate,
return result; minFrameRate.RFrameRate);
return FrameRate.DefaultFrameRate;
} }
if (distinct.Count != 0) if (distinct.Count != 0)
{ {
_logger.LogInformation( logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize", "All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
request.ChannelNumber, request.ChannelNumber,
distinct[0]); distinct[0].RFrameRate);
} }
else else
{ {
_logger.LogInformation( logger.LogInformation(
"No content on channel {ChannelNumber} has frame rate information; will not normalize", "No content on channel {ChannelNumber} has frame rate information; will not normalize",
request.ChannelNumber); request.ChannelNumber);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning( logger.LogWarning(
ex, ex,
"Unexpected error checking frame rates on channel {ChannelNumber}", "Unexpected error checking frame rates on channel {ChannelNumber}",
request.ChannelNumber); request.ChannelNumber);
@ -120,22 +115,4 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
return None; return None;
} }
private int ParseFrameRate(string frameRate)
{
if (!int.TryParse(frameRate, out int fr))
{
string[] split = (frameRate ?? string.Empty).Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
fr = (int)Math.Round(left / (double)right);
}
else
{
fr = 24;
}
}
return fr;
}
} }

6
ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -15,14 +16,17 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
private readonly IFileSystem _fileSystem;
public GetChannelGuideHandler( public GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager, RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem) ILocalFileSystem localFileSystem)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; _recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
} }
@ -39,7 +43,7 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
.ToImmutableHashSet(); .ToImmutableHashSet();
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml"); 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"); return BaseError.New($"Required file {channelsFile} is missing");
} }

8
ErsatzTV.Application/Emby/Commands/CallEmbyCollectionScannerHandler.cs

@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Emby; namespace ErsatzTV.Application.Emby;
@ -21,7 +22,12 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo) IRuntimeInfo runtimeInfo,
ILogger<CallEmbyCollectionScannerHandler> logger) : base(
dbContextFactory,
configElementRepository,
runtimeInfo,
logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

6
ErsatzTV.Application/Emby/Commands/CallEmbyLibraryScannerHandler.cs

@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Emby; namespace ErsatzTV.Application.Emby;
@ -22,8 +23,9 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo,
: base(dbContextFactory, configElementRepository, runtimeInfo) ILogger<CallEmbyLibraryScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

6
ErsatzTV.Application/Emby/Commands/CallEmbyShowScannerHandler.cs

@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Emby; namespace ErsatzTV.Application.Emby;
@ -19,8 +20,9 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo,
: base(dbContextFactory, configElementRepository, runtimeInfo) ILogger<CallEmbyShowScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

16
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel> <AnalysisLevel>latest-Recommended</AnalysisLevel>
@ -11,18 +11,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" /> <PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.9.0" /> <PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" /> <PackageReference Include="Humanizer.Core" Version="3.0.1" />
<PackageReference Include="MediatR" Version="[12.5.0]" /> <PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
<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="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" /> <PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.19.1" /> <PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" /> <PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
</ItemGroup> </ItemGroup>

1
ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs

@ -26,6 +26,7 @@ public record CreateFFmpegProfile(
int AudioBitrate, int AudioBitrate,
int AudioBufferSize, int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode, NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeFramerate, bool NormalizeFramerate,

5
ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs

@ -70,7 +70,12 @@ public class CreateFFmpegProfileHandler :
AudioFormat = request.AudioFormat, AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate, AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize, AudioBufferSize = request.AudioBufferSize,
NormalizeLoudnessMode = request.NormalizeLoudnessMode, NormalizeLoudnessMode = request.NormalizeLoudnessMode,
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? request.TargetLoudness
: null,
AudioChannels = request.AudioChannels, AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate, AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate, NormalizeFramerate = request.NormalizeFramerate,

1
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs

@ -27,6 +27,7 @@ public record UpdateFFmpegProfile(
int AudioBitrate, int AudioBitrate,
int AudioBufferSize, int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode, NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeFramerate, bool NormalizeFramerate,

5
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs

@ -59,7 +59,12 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.AudioFormat = update.AudioFormat; p.AudioFormat = update.AudioFormat;
p.AudioBitrate = update.AudioBitrate; p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize; p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudnessMode = update.NormalizeLoudnessMode; p.NormalizeLoudnessMode = update.NormalizeLoudnessMode;
p.TargetLoudness = update.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? update.TargetLoudness
: null;
p.AudioChannels = update.AudioChannels; p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate; p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeFramerate = update.NormalizeFramerate; p.NormalizeFramerate = update.NormalizeFramerate;

6
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -1,18 +1,18 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.FFmpeg; using ErsatzTV.Application.FFmpeg;
using ErsatzTV.Application.Subtitles; using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.FFmpegProfiles; namespace ErsatzTV.Application.FFmpegProfiles;
public class UpdateFFmpegSettingsHandler( public class UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ChannelWriter<IBackgroundServiceRequest> workerChannel) ChannelWriter<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>> : IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
{ {
@ -35,7 +35,7 @@ public class UpdateFFmpegSettingsHandler(
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name) 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"); return BaseError.New($"{name} path does not exist");
} }

1
ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs

@ -27,6 +27,7 @@ public record FFmpegProfileViewModel(
int AudioBitrate, int AudioBitrate,
int AudioBufferSize, int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode, NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeFramerate, bool NormalizeFramerate,

1
ErsatzTV.Application/FFmpegProfiles/Mapper.cs

@ -29,6 +29,7 @@ internal static class Mapper
profile.AudioBitrate, profile.AudioBitrate,
profile.AudioBufferSize, profile.AudioBufferSize,
profile.NormalizeLoudnessMode, profile.NormalizeLoudnessMode,
profile.TargetLoudness,
profile.AudioChannels, profile.AudioChannels,
profile.AudioSampleRate, profile.AudioSampleRate,
profile.NormalizeFramerate, profile.NormalizeFramerate,

10
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs

@ -5,18 +5,14 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles; namespace ErsatzTV.Application.FFmpegProfiles;
public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>> public class GetFFmpegProfileByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetFFmpegProfileByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<FFmpegProfileViewModel>> Handle( public async Task<Option<FFmpegProfileViewModel>> Handle(
GetFFmpegProfileById request, GetFFmpegProfileById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FFmpegProfiles return await dbContext.FFmpegProfiles
.Include(p => p.Resolution) .Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken) .SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken)

24
ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -10,6 +11,7 @@ namespace ErsatzTV.Application.Graphics;
public class RefreshGraphicsElementsHandler( public class RefreshGraphicsElementsHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IGraphicsElementLoader graphicsElementLoader, IGraphicsElementLoader graphicsElementLoader,
ILogger<RefreshGraphicsElementsHandler> logger) ILogger<RefreshGraphicsElementsHandler> logger)
@ -24,7 +26,7 @@ public class RefreshGraphicsElementsHandler(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var missing = allExisting var missing = allExisting
.Where(e => !localFileSystem.FileExists(e.Path) || (Path.GetExtension(e.Path) != ".yml" && Path.GetExtension(e.Path) != ".yaml")) .Where(e => !fileSystem.File.Exists(e.Path) || (Path.GetExtension(e.Path) != ".yml" && Path.GetExtension(e.Path) != ".yaml"))
.ToList(); .ToList();
foreach (GraphicsElement existing in missing) foreach (GraphicsElement existing in missing)
@ -121,6 +123,26 @@ public class RefreshGraphicsElementsHandler(
await dbContext.AddAsync(graphicsElement, cancellationToken); await dbContext.AddAsync(graphicsElement, cancellationToken);
} }
// add new script elements
var newScriptPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsScriptTemplatesFolder, "*.yml", "*.yaml")
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
foreach (string path in newScriptPaths)
{
logger.LogDebug("Adding new graphics element from file {File}", path);
var graphicsElement = new GraphicsElement
{
Path = path,
Kind = GraphicsElementKind.Script
};
await TryRefreshName(graphicsElement, cancellationToken);
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }

1
ErsatzTV.Application/Graphics/Mapper.cs

@ -13,6 +13,7 @@ public static class Mapper
GraphicsElementKind.Image => $"image/{fileName}", GraphicsElementKind.Image => $"image/{fileName}",
GraphicsElementKind.Subtitle => $"subtitle/{fileName}", GraphicsElementKind.Subtitle => $"subtitle/{fileName}",
GraphicsElementKind.Motion => $"motion/{fileName}", GraphicsElementKind.Motion => $"motion/{fileName}",
GraphicsElementKind.Script => $"script/{fileName}",
_ => graphicsElement.Path _ => graphicsElement.Path
}; };

8
ErsatzTV.Application/Jellyfin/Commands/CallJellyfinCollectionScannerHandler.cs

@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin; namespace ErsatzTV.Application.Jellyfin;
@ -21,7 +22,12 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo) IRuntimeInfo runtimeInfo,
ILogger<CallJellyfinCollectionScannerHandler> logger) : base(
dbContextFactory,
configElementRepository,
runtimeInfo,
logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

6
ErsatzTV.Application/Jellyfin/Commands/CallJellyfinLibraryScannerHandler.cs

@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin; namespace ErsatzTV.Application.Jellyfin;
@ -22,8 +23,9 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo,
: base(dbContextFactory, configElementRepository, runtimeInfo) ILogger<CallJellyfinLibraryScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

6
ErsatzTV.Application/Jellyfin/Commands/CallJellyfinShowScannerHandler.cs

@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin; namespace ErsatzTV.Application.Jellyfin;
@ -19,8 +20,9 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo,
: base(dbContextFactory, configElementRepository, runtimeInfo) ILogger<CallJellyfinShowScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

8
ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs

@ -11,13 +11,16 @@ using Serilog;
using Serilog.Core; using Serilog.Core;
using Serilog.Events; using Serilog.Events;
using Serilog.Formatting.Compact.Reader; using Serilog.Formatting.Compact.Reader;
using Microsoft.Extensions.Logging;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace ErsatzTV.Application.Libraries; namespace ErsatzTV.Application.Libraries;
public abstract class CallLibraryScannerHandler<TRequest>( public abstract class CallLibraryScannerHandler<TRequest>(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo,
ILogger logger)
{ {
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/scan/{scanId}"; protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/scan/{scanId}";
@ -42,6 +45,7 @@ public abstract class CallLibraryScannerHandler<TRequest>(
if (process.ExitCode != 0) if (process.ExitCode != 0)
{ {
logger.LogWarning("ErsatzTV.Scanner exited with code {ExitCode}", process.ExitCode);
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}"); return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
} }
} }
@ -64,7 +68,7 @@ public abstract class CallLibraryScannerHandler<TRequest>(
// writes in UTC // writes in UTC
LogEvent logEvent = LogEventReader.ReadFromString(s); LogEvent logEvent = LogEventReader.ReadFromString(s);
ILogger log = Log.Logger; Serilog.ILogger log = Log.Logger;
if (logEvent.Properties.TryGetValue("SourceContext", out LogEventPropertyValue property)) if (logEvent.Properties.TryGetValue("SourceContext", out LogEventPropertyValue property))
{ {
log = log.ForContext( log = log.ForContext(

2
ErsatzTV.Application/MediaCards/Mapper.cs

@ -161,7 +161,7 @@ internal static class Mapper
remoteStreamMetadata.Title, remoteStreamMetadata.Title,
remoteStreamMetadata.OriginalTitle, remoteStreamMetadata.OriginalTitle,
remoteStreamMetadata.SortTitle, remoteStreamMetadata.SortTitle,
string.Empty, // TODO: thumbnail? GetThumbnail(remoteStreamMetadata, None, None),
remoteStreamMetadata.RemoteStream.State); remoteStreamMetadata.RemoteStream.State);
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) => internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>

2
ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs

@ -67,7 +67,7 @@ public class CreateSmartCollectionHandler :
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name)); .Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createSmartCollection.Name) var result2 = Optional(createSmartCollection.Name)
.Where(name => !allNames.Contains(name)) .Where(name => !allNames.Contains(name, StringComparer.OrdinalIgnoreCase))
.ToValidation<BaseError>("SmartCollection name must be unique"); .ToValidation<BaseError>("SmartCollection name must be unique");
return (result1, result2).Apply((_, _) => createSmartCollection.Name); return (result1, result2).Apply((_, _) => createSmartCollection.Name);

26
ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs

@ -12,7 +12,9 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, UpdateSmartCollectionResult>> public class
UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection,
Either<BaseError, UpdateSmartCollectionResult>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
@ -71,7 +73,8 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
private static Task<Validation<BaseError, SmartCollection>> Validate( private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext, TvContext dbContext,
UpdateSmartCollection request, UpdateSmartCollection request,
CancellationToken cancellationToken) => SmartCollectionMustExist(dbContext, request, cancellationToken); CancellationToken cancellationToken) => ValidateName(dbContext, request)
.BindT(_ => SmartCollectionMustExist(dbContext, request, cancellationToken));
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist( private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
TvContext dbContext, TvContext dbContext,
@ -80,4 +83,23 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
dbContext.SmartCollections dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken) .SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist.")); .Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
UpdateSmartCollection updateCollection)
{
List<string> allNames = await dbContext.SmartCollections
.Where(c => c.Id != updateCollection.Id)
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = updateCollection.NotEmpty(c => c.Name)
.Bind(_ => updateCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(updateCollection.Name)
.Where(name => !allNames.Contains(name, StringComparer.OrdinalIgnoreCase))
.ToValidation<BaseError>("SmartCollection name must be unique");
return (result1, result2).Apply((_, _) => updateCollection.Name);
}
} }

8
ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItemsHandler.cs

@ -57,14 +57,6 @@ public class GetPlaylistItemsHandler(IDbContextFactory<TvContext> dbContextFacto
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (allItems.All(bi => !bi.IncludeInProgramGuide))
{
foreach (PlaylistItem bi in allItems)
{
bi.IncludeInProgramGuide = true;
}
}
return allItems.Map(Mapper.ProjectToViewModel).ToList(); return allItems.Map(Mapper.ProjectToViewModel).ToList();
} }
} }

6
ErsatzTV.Application/MediaSources/Commands/CallLocalLibraryScannerHandler.cs

@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.MediaSources; namespace ErsatzTV.Application.MediaSources;
@ -22,8 +23,9 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo,
: base(dbContextFactory, configElementRepository, runtimeInfo) ILogger<CallLocalLibraryScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

10
ErsatzTV.Application/Playouts/Commands/CreateExternalJsonPlayoutHandler.cs

@ -1,8 +1,8 @@
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -14,16 +14,16 @@ namespace ErsatzTV.Application.Playouts;
public class CreateExternalJsonPlayoutHandler public class CreateExternalJsonPlayoutHandler
: IRequestHandler<CreateExternalJsonPlayout, Either<BaseError, CreatePlayoutResponse>> : IRequestHandler<CreateExternalJsonPlayout, Either<BaseError, CreatePlayoutResponse>>
{ {
private readonly IFileSystem _fileSystem;
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
public CreateExternalJsonPlayoutHandler( public CreateExternalJsonPlayoutHandler(
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ChannelWriter<IBackgroundServiceRequest> channel, ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory) IDbContextFactory<TvContext> dbContextFactory)
{ {
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_channel = channel; _channel = channel;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
} }
@ -76,7 +76,7 @@ public class CreateExternalJsonPlayoutHandler
private Validation<BaseError, string> ValidateExternalJsonFile(CreateExternalJsonPlayout request) 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!"); return BaseError.New("External Json File does not exist!");
} }

30
ErsatzTV.Application/Playouts/Commands/CreateScriptedPlayoutHandler.cs

@ -1,4 +1,5 @@
using System.CommandLine.Parsing; using System.CommandLine.Parsing;
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -12,28 +13,17 @@ using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
public class CreateScriptedPlayoutHandler public class CreateScriptedPlayoutHandler(
IFileSystem fileSystem,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateScriptedPlayout, Either<BaseError, CreatePlayoutResponse>> : 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( public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
CreateScriptedPlayout request, CreateScriptedPlayout request,
CancellationToken cancellationToken) 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); Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(playout => PersistPlayout(dbContext, playout, 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.Playouts.AddAsync(playout, cancellationToken);
await dbContext.SaveChangesAsync(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) if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{ {
await _channel.WriteAsync( await channel.WriteAsync(
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false), new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
cancellationToken); cancellationToken);
} }
await _channel.WriteAsync(new RefreshChannelList(), cancellationToken); await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreatePlayoutResponse(playout.Id); return new CreatePlayoutResponse(playout.Id);
} }
@ -91,7 +81,7 @@ public class CreateScriptedPlayoutHandler
{ {
var args = CommandLineParser.SplitCommandLine(request.ScheduleFile).ToList(); var args = CommandLineParser.SplitCommandLine(request.ScheduleFile).ToList();
string scriptFile = args[0]; string scriptFile = args[0];
if (!_localFileSystem.FileExists(scriptFile)) if (!fileSystem.File.Exists(scriptFile))
{ {
return BaseError.New("Scripted schedule does not exist!"); return BaseError.New("Scripted schedule does not exist!");
} }

31
ErsatzTV.Application/Playouts/Commands/CreateSequentialPlayoutHandler.cs

@ -1,8 +1,8 @@
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -11,28 +11,17 @@ using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
public class CreateSequentialPlayoutHandler public class CreateSequentialPlayoutHandler(
IFileSystem fileSystem,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateSequentialPlayout, Either<BaseError, CreatePlayoutResponse>> : 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( public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
CreateSequentialPlayout request, CreateSequentialPlayout request,
CancellationToken cancellationToken) 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); Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(playout => PersistPlayout(dbContext, playout, 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.Playouts.AddAsync(playout, cancellationToken);
await dbContext.SaveChangesAsync(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) if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{ {
await _channel.WriteAsync( await channel.WriteAsync(
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false), new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
cancellationToken); cancellationToken);
} }
await _channel.WriteAsync(new RefreshChannelList(), cancellationToken); await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreatePlayoutResponse(playout.Id); return new CreatePlayoutResponse(playout.Id);
} }
@ -87,7 +76,7 @@ public class CreateSequentialPlayoutHandler
private Validation<BaseError, string> ValidateYamlFile(CreateSequentialPlayout request) 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!"); return BaseError.New("Sequential schedule does not exist!");
} }

36
ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs

@ -1,8 +1,8 @@
using System.Threading.Channels; using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Notifications; using ErsatzTV.Core.Notifications;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -10,28 +10,16 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts; 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 IMediator _mediator;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public DeletePlayoutHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem,
IMediator mediator)
{
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
_mediator = mediator;
}
public async Task<Either<BaseError, Unit>> Handle(DeletePlayout request, CancellationToken cancellationToken) public async Task<Either<BaseError, Unit>> Handle(DeletePlayout request, CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playout> maybePlayout = await dbContext.Playouts Option<Playout> maybePlayout = await dbContext.Playouts
.Include(p => p.Channel) .Include(p => p.Channel)
@ -44,15 +32,15 @@ public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseEr
// delete channel data from channel guide cache // delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml"); string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml");
if (_localFileSystem.FileExists(cacheFile)) if (fileSystem.File.Exists(cacheFile))
{ {
File.Delete(cacheFile); File.Delete(cacheFile);
} }
// refresh channel list to remove channel that has no playout // 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); await mediator.Publish(new PlayoutUpdatedNotification(playout.Id, false), cancellationToken);
} }
return maybePlayout return maybePlayout

6
ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs

@ -1,9 +1,9 @@
using System.CommandLine.Parsing; using System.CommandLine.Parsing;
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -14,7 +14,7 @@ public class
UpdateScriptedPlayoutHandler( UpdateScriptedPlayoutHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILocalFileSystem localFileSystem) IFileSystem fileSystem)
: IRequestHandler<UpdateScriptedPlayout, : IRequestHandler<UpdateScriptedPlayout,
Either<BaseError, PlayoutNameViewModel>> Either<BaseError, PlayoutNameViewModel>>
{ {
@ -63,7 +63,7 @@ public class
{ {
var args = CommandLineParser.SplitCommandLine(request.ScheduleFile).ToList(); var args = CommandLineParser.SplitCommandLine(request.ScheduleFile).ToList();
string scriptFile = args[0]; string scriptFile = args[0];
if (!localFileSystem.FileExists(scriptFile)) if (!fileSystem.File.Exists(scriptFile))
{ {
return BaseError.New("Scripted schedule does not exist!"); return BaseError.New("Scripted schedule does not exist!");
} }

8
ErsatzTV.Application/Plex/Commands/CallPlexCollectionScannerHandler.cs

@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex; namespace ErsatzTV.Application.Plex;
@ -21,7 +22,12 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo) IRuntimeInfo runtimeInfo,
ILogger<CallPlexCollectionScannerHandler> logger) : base(
dbContextFactory,
configElementRepository,
runtimeInfo,
logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

6
ErsatzTV.Application/Plex/Commands/CallPlexLibraryScannerHandler.cs

@ -9,6 +9,7 @@ using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex; namespace ErsatzTV.Application.Plex;
@ -22,8 +23,9 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo,
: base(dbContextFactory, configElementRepository, runtimeInfo) ILogger<CallPlexLibraryScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

5
ErsatzTV.Application/Plex/Commands/CallPlexNetworkScannerHandler.cs

@ -10,6 +10,7 @@ using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex; namespace ErsatzTV.Application.Plex;
@ -22,7 +23,9 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo) IRuntimeInfo runtimeInfo,
ILogger<CallPlexNetworkScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

6
ErsatzTV.Application/Plex/Commands/CallPlexShowScannerHandler.cs

@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex; namespace ErsatzTV.Application.Plex;
@ -19,8 +20,9 @@ public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizeP
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScannerProxyService scannerProxyService, IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo,
: base(dbContextFactory, configElementRepository, runtimeInfo) ILogger<CallPlexShowScannerHandler> logger)
: base(dbContextFactory, configElementRepository, runtimeInfo, logger)
{ {
_scannerProxyService = scannerProxyService; _scannerProxyService = scannerProxyService;
} }

12
ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs

@ -1,4 +1,5 @@
using System.Threading.Channels; using System.IO.Abstractions;
using System.Threading.Channels;
using Bugsnag; using Bugsnag;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Graphics; using ErsatzTV.Application.Graphics;
@ -11,6 +12,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.OutputFormat; using ErsatzTV.FFmpeg.OutputFormat;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@ -21,6 +23,7 @@ namespace ErsatzTV.Application.Streaming;
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>> public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{ {
private readonly IClient _client; private readonly IClient _client;
private readonly IFileSystem _fileSystem;
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService; private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IGraphicsEngine _graphicsEngine; private readonly IGraphicsEngine _graphicsEngine;
@ -40,6 +43,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
IMediator mediator, IMediator mediator,
IClient client, IClient client,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<StartFFmpegSessionHandler> logger, ILogger<StartFFmpegSessionHandler> logger,
ILogger<HlsSessionWorker> sessionWorkerLogger, ILogger<HlsSessionWorker> sessionWorkerLogger,
@ -54,6 +58,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_serviceScopeFactory = serviceScopeFactory; _serviceScopeFactory = serviceScopeFactory;
_mediator = mediator; _mediator = mediator;
_client = client; _client = client;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_logger = logger; _logger = logger;
_sessionWorkerLogger = sessionWorkerLogger; _sessionWorkerLogger = sessionWorkerLogger;
@ -78,7 +83,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken) .GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken)
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1))); .Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1)));
Option<int> targetFramerate = await _mediator.Send( Option<FrameRate> targetFramerate = await _mediator.Send(
new GetChannelFramerate(request.ChannelNumber), new GetChannelFramerate(request.ChannelNumber),
cancellationToken); cancellationToken);
@ -118,7 +123,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
return Unit.Default; return Unit.Default;
} }
private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<int> targetFramerate) => private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<FrameRate> targetFramerate) =>
request.Mode switch request.Mode switch
{ {
_ => new HlsSessionWorker( _ => new HlsSessionWorker(
@ -129,6 +134,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_hlsPlaylistFilter, _hlsPlaylistFilter,
_hlsInitSegmentCache, _hlsInitSegmentCache,
_configElementRepository, _configElementRepository,
_fileSystem,
_localFileSystem, _localFileSystem,
_sessionWorkerLogger, _sessionWorkerLogger,
targetFramerate) targetFramerate)

36
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -1,6 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using System.IO.Pipelines; using System.IO.Pipelines;
using System.Text; using System.Text;
using System.Timers; using System.Timers;
@ -15,6 +16,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.OutputFormat; using ErsatzTV.FFmpeg.OutputFormat;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -30,6 +32,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private readonly IHlsInitSegmentCache _hlsInitSegmentCache; private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = []; private readonly Dictionary<long, int> _discontinuityMap = [];
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IFileSystem _fileSystem;
private readonly IGraphicsEngine _graphicsEngine; private readonly IGraphicsEngine _graphicsEngine;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter; private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
@ -37,7 +40,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly SemaphoreSlim _slim = new(1, 1); private readonly SemaphoreSlim _slim = new(1, 1);
private readonly Lock _sync = new(); private readonly Lock _sync = new();
private readonly Option<int> _targetFramerate; private readonly Option<FrameRate> _targetFramerate;
private CancellationTokenSource _cancellationTokenSource; private CancellationTokenSource _cancellationTokenSource;
private string _channelNumber; private string _channelNumber;
private DateTimeOffset _channelStart; private DateTimeOffset _channelStart;
@ -60,9 +63,10 @@ public class HlsSessionWorker : IHlsSessionWorker
IHlsPlaylistFilter hlsPlaylistFilter, IHlsPlaylistFilter hlsPlaylistFilter,
IHlsInitSegmentCache hlsInitSegmentCache, IHlsInitSegmentCache hlsInitSegmentCache,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<HlsSessionWorker> logger, ILogger<HlsSessionWorker> logger,
Option<int> targetFramerate) Option<FrameRate> targetFramerate)
{ {
_serviceScope = serviceScopeFactory.CreateScope(); _serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>(); _mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
@ -72,6 +76,7 @@ public class HlsSessionWorker : IHlsSessionWorker
_hlsInitSegmentCache = hlsInitSegmentCache; _hlsInitSegmentCache = hlsInitSegmentCache;
_hlsPlaylistFilter = hlsPlaylistFilter; _hlsPlaylistFilter = hlsPlaylistFilter;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_logger = logger; _logger = logger;
_targetFramerate = targetFramerate; _targetFramerate = targetFramerate;
@ -308,7 +313,7 @@ public class HlsSessionWorker : IHlsSessionWorker
string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8"); string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
_logger.LogDebug("Waiting for playlist to exist"); _logger.LogDebug("Waiting for playlist to exist");
while (!_localFileSystem.FileExists(playlistFileName)) while (!_fileSystem.File.Exists(playlistFileName))
{ {
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
} }
@ -483,6 +488,11 @@ public class HlsSessionWorker : IHlsSessionWorker
foreach (PlayoutItemProcessModel processModel in result.RightAsEnumerable()) foreach (PlayoutItemProcessModel processModel in result.RightAsEnumerable())
{ {
if (!realtime && !processModel.IsWorkingAhead)
{
_logger.LogDebug("HLS session throttling (NOT working ahead) based on playout item");
}
await TrimAndDelete(cancellationToken); await TrimAndDelete(cancellationToken);
// increment discontinuity sequence and store with segment key (generated at) // increment discontinuity sequence and store with segment key (generated at)
@ -679,9 +689,9 @@ public class HlsSessionWorker : IHlsSessionWorker
var generatedAtHash = new System.Collections.Generic.HashSet<long>(); var generatedAtHash = new System.Collections.Generic.HashSet<long>();
// delete old segments // delete old segments
var allSegments = Directory.GetFiles(_workingDirectory, "live*.ts") var allSegments = _fileSystem.Directory.GetFiles(_workingDirectory, "live*.ts")
.Append(Directory.GetFiles(_workingDirectory, "live*.mp4")) .Append(_fileSystem.Directory.GetFiles(_workingDirectory, "live*.mp4"))
.Append(Directory.GetFiles(_workingDirectory, "live*.m4s")) .Append(_fileSystem.Directory.GetFiles(_workingDirectory, "live*.m4s"))
.Map(file => .Map(file =>
{ {
string fileName = Path.GetFileName(file); string fileName = Path.GetFileName(file);
@ -699,7 +709,7 @@ public class HlsSessionWorker : IHlsSessionWorker
}) })
.ToList(); .ToList();
var allInits = Directory.GetFiles(_workingDirectory, "*init.mp4") var allInits = _fileSystem.Directory.GetFiles(_workingDirectory, "*init.mp4")
.Map(file => long.TryParse(Path.GetFileName(file).Split('_')[0], out long generatedAt) && !generatedAtHash.Contains(generatedAt) .Map(file => long.TryParse(Path.GetFileName(file).Split('_')[0], out long generatedAt) && !generatedAtHash.Contains(generatedAt)
? new Segment(file, 0, generatedAt) ? new Segment(file, 0, generatedAt)
: Option<Segment>.None) : Option<Segment>.None)
@ -739,7 +749,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
try try
{ {
File.Delete(segment.File); _fileSystem.File.Delete(segment.File);
} }
catch (IOException) catch (IOException)
{ {
@ -752,12 +762,12 @@ public class HlsSessionWorker : IHlsSessionWorker
private async Task RefreshInits() private async Task RefreshInits()
{ {
var allSegments = Directory.GetFiles(_workingDirectory, "live*.m4s") var allSegments = _fileSystem.Directory.GetFiles(_workingDirectory, "live*.m4s")
.Map(Path.GetFileName) .Map(Path.GetFileName)
.Map(s => s.Split("_")[1]) .Map(s => s.Split("_")[1])
.ToHashSet(); .ToHashSet();
foreach (string file in Directory.GetFiles(_workingDirectory, "*init.mp4")) foreach (string file in _fileSystem.Directory.GetFiles(_workingDirectory, "*init.mp4"))
{ {
string key = Path.GetFileName(file).Split("_")[0]; string key = Path.GetFileName(file).Split("_")[0];
if (allSegments.Contains(key)) if (allSegments.Contains(key))
@ -812,9 +822,9 @@ public class HlsSessionWorker : IHlsSessionWorker
private async Task<Option<string[]>> ReadPlaylistLines(CancellationToken cancellationToken) private async Task<Option<string[]>> ReadPlaylistLines(CancellationToken cancellationToken)
{ {
string fileName = PlaylistFileName(); string fileName = PlaylistFileName();
if (File.Exists(fileName)) if (_fileSystem.File.Exists(fileName))
{ {
return await File.ReadAllLinesAsync(fileName, cancellationToken); return await _fileSystem.File.ReadAllLinesAsync(fileName, cancellationToken);
} }
_logger.LogDebug("Playlist does not exist at expected location {File}", fileName); _logger.LogDebug("Playlist does not exist at expected location {File}", fileName);
@ -824,7 +834,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private async Task WritePlaylist(string playlist, CancellationToken cancellationToken) private async Task WritePlaylist(string playlist, CancellationToken cancellationToken)
{ {
string fileName = PlaylistFileName(); string fileName = PlaylistFileName();
await File.WriteAllTextAsync(fileName, playlist, cancellationToken); await _fileSystem.File.WriteAllTextAsync(fileName, playlist, cancellationToken);
} }
private string PlaylistFileName() => Path.Combine(_workingDirectory, "live.m3u8"); private string PlaylistFileName() => Path.Combine(_workingDirectory, "live.m3u8");

59
ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs

@ -3,11 +3,54 @@ using ErsatzTV.Core.Interfaces.Streaming;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public record PlayoutItemProcessModel( public class PlayoutItemProcessModel
Command Process, {
Option<GraphicsEngineContext> GraphicsEngineContext, public PlayoutItemProcessModel(
Option<TimeSpan> MaybeDuration, Command process,
DateTimeOffset Until, Option<GraphicsEngineContext> graphicsEngineContext,
bool IsComplete, Option<TimeSpan> maybeDuration,
Option<long> SegmentKey, DateTimeOffset until,
Option<int> MediaItemId); bool isComplete,
Option<long> segmentKey,
Option<int> mediaItemId,
Option<TimeSpan> playoutOffset,
bool isWorkingAhead)
{
Process = process;
GraphicsEngineContext = graphicsEngineContext;
MaybeDuration = maybeDuration;
Until = until;
IsComplete = isComplete;
SegmentKey = segmentKey;
MediaItemId = mediaItemId;
IsWorkingAhead = isWorkingAhead;
// undo the offset applied in FFmpegProcessHandler
// so we don't continually walk backward/forward in time by the offset amount
foreach (TimeSpan offset in playoutOffset)
{
foreach (long key in SegmentKey)
{
SegmentKey = key + (long)offset.TotalSeconds;
}
Until += offset;
}
}
public Command Process { get; init; }
public Option<GraphicsEngineContext> GraphicsEngineContext { get; init; }
public Option<TimeSpan> MaybeDuration { get; init; }
public DateTimeOffset Until { get; init; }
public bool IsComplete { get; init; }
public Option<long> SegmentKey { get; init; }
public Option<int> MediaItemId { get; init; }
public bool IsWorkingAhead { get; init; }
}

4
ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs

@ -45,6 +45,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
DateTimeOffset.MaxValue, DateTimeOffset.MaxValue,
true, true,
Option<long>.None, Option<long>.None,
Option<int>.None); Option<int>.None,
Option<TimeSpan>.None,
false);
} }
} }

10
ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs

@ -21,12 +21,10 @@ public class GetErrorProcessHandler(
string ffprobePath, string ffprobePath,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
DateTimeOffset now = DateTimeOffset.Now;
Command process = await ffmpegProcessService.ForError( Command process = await ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now, request.Now,
request.MaybeDuration, request.MaybeDuration,
request.ErrorMessage, request.ErrorMessage,
request.HlsRealtime, request.HlsRealtime,
@ -42,7 +40,9 @@ public class GetErrorProcessHandler(
request.MaybeDuration, request.MaybeDuration,
request.Until, request.Until,
true, true,
now.ToUnixTimeSeconds(), request.Now.ToUnixTimeSeconds(),
Option<int>.None); Option<int>.None,
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
} }
} }

8
ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs

@ -1,11 +1,11 @@
using System.Text; using System.IO.Abstractions;
using System.Text;
using Bugsnag; using Bugsnag;
using CliWrap; using CliWrap;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -14,7 +14,7 @@ namespace ErsatzTV.Application.Streaming;
public class GetLastPtsTimeHandler( public class GetLastPtsTimeHandler(
IClient client, IClient client,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILogger<GetLastPtsTimeHandler> logger) ILogger<GetLastPtsTimeHandler> logger)
@ -168,7 +168,7 @@ public class GetLastPtsTimeHandler(
string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8"); string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
string playlistContents = string.Empty; string playlistContents = string.Empty;
if (localFileSystem.FileExists(playlistFileName)) if (fileSystem.File.Exists(playlistFileName))
{ {
playlistContents = await File.ReadAllTextAsync(playlistFileName); playlistContents = await File.ReadAllTextAsync(playlistFileName);
} }

3
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
@ -10,7 +11,7 @@ public record GetPlayoutItemProcessByChannelNumber(
bool HlsRealtime, bool HlsRealtime,
DateTimeOffset ChannelStart, DateTimeOffset ChannelStart,
TimeSpan PtsOffset, TimeSpan PtsOffset,
Option<int> TargetFramerate, Option<FrameRate> TargetFramerate,
bool IsTroubleshooting, bool IsTroubleshooting,
Option<int> FFmpegProfileId) : FFmpegProcessRequest( Option<int> FFmpegProfileId) : FFmpegProcessRequest(
ChannelNumber, ChannelNumber,

131
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -1,4 +1,5 @@
using CliWrap; using System.IO.Abstractions;
using CliWrap;
using Dapper; using Dapper;
using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -11,7 +12,6 @@ using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
@ -27,12 +27,14 @@ namespace ErsatzTV.Application.Streaming;
public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber> public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{ {
private static readonly Random FallbackRandom = new();
private readonly IArtistRepository _artistRepository; private readonly IArtistRepository _artistRepository;
private readonly IEmbyPathReplacementService _embyPathReplacementService; private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IExternalJsonPlayoutItemProvider _externalJsonPlayoutItemProvider; private readonly IExternalJsonPlayoutItemProvider _externalJsonPlayoutItemProvider;
private readonly IFFmpegProcessService _ffmpegProcessService; private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IFileSystem _fileSystem;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger; private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator; private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
@ -47,7 +49,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
public GetPlayoutItemProcessByChannelNumberHandler( public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider, IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider,
IPlexPathReplacementService plexPathReplacementService, IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService,
@ -64,7 +66,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
: base(dbContextFactory) : base(dbContextFactory)
{ {
_ffmpegProcessService = ffmpegProcessService; _ffmpegProcessService = ffmpegProcessService;
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider; _externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
_plexPathReplacementService = plexPathReplacementService; _plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService; _jellyfinPathReplacementService = jellyfinPathReplacementService;
@ -284,9 +286,20 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
bool isComplete = true; bool isComplete = true;
bool effectiveRealtime = request.HlsRealtime;
// only work ahead on fallback filler up to 3 minutes in duration
// since we always transcode a full fallback filler item
if (!effectiveRealtime &&
playoutItemWithPath.PlayoutItem.FillerKind is FillerKind.Fallback &&
duration > TimeSpan.FromMinutes(3))
{
effectiveRealtime = true;
}
TimeSpan limit = TimeSpan.Zero; TimeSpan limit = TimeSpan.Zero;
if (!request.HlsRealtime) if (!effectiveRealtime)
{ {
// if we are working ahead, limit to 44s (multiple of segment size) // if we are working ahead, limit to 44s (multiple of segment size)
limit = TimeSpan.FromSeconds(44); limit = TimeSpan.FromSeconds(44);
@ -319,7 +332,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
now, now,
duration, duration,
$"DEBUG_NO_SYNC:\n{Mapper.GetDisplayTitle(playoutItemWithPath.PlayoutItem.MediaItem, Option<string>.None)}\nFrom: {start} To: {finish}", $"DEBUG_NO_SYNC:\n{Mapper.GetDisplayTitle(playoutItemWithPath.PlayoutItem.MediaItem, Option<string>.None)}\nFrom: {start} To: {finish}",
request.HlsRealtime, effectiveRealtime,
request.PtsOffset, request.PtsOffset,
channel.FFmpegProfile.VaapiDisplay, channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver, channel.FFmpegProfile.VaapiDriver,
@ -332,8 +345,10 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
duration, duration,
finish, finish,
true, true,
now.ToUnixTimeSeconds(), effectiveNow.ToUnixTimeSeconds(),
Option<int>.None); Option<int>.None,
Optional(channel.PlayoutOffset),
!effectiveRealtime);
} }
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion(); MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
@ -442,7 +457,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDriver, channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice, channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames), Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
hlsRealtime: request.HlsRealtime, effectiveRealtime,
playoutItemWithPath.PlayoutItem.MediaItem is RemoteStream { IsLive: true } playoutItemWithPath.PlayoutItem.MediaItem is RemoteStream { IsLive: true }
? StreamInputKind.Live ? StreamInputKind.Live
: StreamInputKind.Vod, : StreamInputKind.Vod,
@ -463,7 +478,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
finish, finish,
isComplete, isComplete,
effectiveNow.ToUnixTimeSeconds(), effectiveNow.ToUnixTimeSeconds(),
playoutItemResult.MediaItemId); playoutItemResult.MediaItemId,
Optional(channel.PlayoutOffset),
!effectiveRealtime);
return Right<BaseError, PlayoutItemProcessModel>(result); return Right<BaseError, PlayoutItemProcessModel>(result);
} }
@ -480,6 +497,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<TimeSpan> maybeDuration = maybeNextStart.Map(s => s - now); Option<TimeSpan> maybeDuration = maybeNextStart.Map(s => s - now);
// limit working ahead on errors to 1 minute
if (!request.HlsRealtime && maybeDuration.IfNone(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
{
maybeNextStart = now.AddMinutes(1);
maybeDuration = TimeSpan.FromMinutes(1);
}
DateTimeOffset finish = maybeNextStart.Match(s => s, () => now); DateTimeOffset finish = maybeNextStart.Match(s => s, () => now);
if (request.IsTroubleshooting) if (request.IsTroubleshooting)
@ -519,7 +543,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
finish, finish,
true, true,
now.ToUnixTimeSeconds(), now.ToUnixTimeSeconds(),
Option<int>.None); Option<int>.None,
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
case PlayoutItemDoesNotExistOnDisk: case PlayoutItemDoesNotExistOnDisk:
Command doesNotExistProcess = await _ffmpegProcessService.ForError( Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
@ -541,7 +567,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
finish, finish,
true, true,
now.ToUnixTimeSeconds(), now.ToUnixTimeSeconds(),
Option<int>.None); Option<int>.None,
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
default: default:
Command errorProcess = await _ffmpegProcessService.ForError( Command errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
@ -563,7 +591,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
finish, finish,
true, true,
now.ToUnixTimeSeconds(), now.ToUnixTimeSeconds(),
Option<int>.None); Option<int>.None,
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
} }
} }
@ -706,8 +736,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
collectionKey, collectionKey,
cancellationToken); cancellationToken);
// TODO: shuffle? does it really matter since we loop anyway // ignore the fallback filler preset if it has no items
MediaItem item = items[new Random().Next(items.Count)]; if (items.Count == 0)
{
break;
}
// get a random item
MediaItem item = items[FallbackRandom.Next(items.Count)];
Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems
.Filter(pi => pi.Playout.ChannelId == (channel.MirrorSourceChannelId ?? channel.Id)) .Filter(pi => pi.Playout.ChannelId == (channel.MirrorSourceChannelId ?? channel.Id))
@ -729,15 +765,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.Filter(ms => ms.MediaVersionId == version.Id) .Filter(ms => ms.MediaVersionId == version.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
DateTimeOffset finish = maybeDuration.Match( // always play min(duration to next item, version.Duration)
// next playout item exists TimeSpan duration = maybeDuration.IfNone(version.Duration);
// loop until it starts if (version.Duration < duration)
now.Add, {
// no next playout item exists duration = version.Duration;
// loop for 5 minutes if less than 30s, otherwise play full item }
() => version.Duration < TimeSpan.FromSeconds(30)
? now.AddMinutes(5) DateTimeOffset finish = now.Add(duration);
: now.Add(version.Duration));
var playoutItem = new PlayoutItem var playoutItem = new PlayoutItem
{ {
@ -747,7 +782,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Finish = finish.UtcDateTime, Finish = finish.UtcDateTime,
FillerKind = FillerKind.Fallback, FillerKind = FillerKind.Fallback,
InPoint = TimeSpan.Zero, InPoint = TimeSpan.Zero,
OutPoint = version.Duration, OutPoint = duration,
DisableWatermarks = !fallbackPreset.AllowWatermarks, DisableWatermarks = !fallbackPreset.AllowWatermarks,
Watermarks = [], Watermarks = [],
PlayoutItemWatermarks = [], PlayoutItemWatermarks = [],
@ -766,7 +801,11 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
PlayoutItem playoutItem, PlayoutItem playoutItem,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
string path = await GetPlayoutItemPath(playoutItem, cancellationToken); string path = await playoutItem.MediaItem.GetLocalPath(
_plexPathReplacementService,
_jellyfinPathReplacementService,
_embyPathReplacementService,
cancellationToken);
if (_isDebugNoSync) if (_isDebugNoSync)
{ {
@ -775,7 +814,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
} }
// check filesystem first // check filesystem first
if (_localFileSystem.FileExists(path)) if (_fileSystem.File.Exists(path))
{ {
if (playoutItem.MediaItem is RemoteStream remoteStream) if (playoutItem.MediaItem is RemoteStream remoteStream)
{ {
@ -848,42 +887,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return new PlayoutItemDoesNotExistOnDisk(path); return new PlayoutItemDoesNotExistOnDisk(path);
} }
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem, CancellationToken cancellationToken)
{
MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
MediaFile file = version.MediaFiles.Head();
string path = file.Path;
return playoutItem.MediaItem switch
{
PlexMovie plexMovie => await _plexPathReplacementService.GetReplacementPlexPath(
plexMovie.LibraryPathId,
path,
cancellationToken),
PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath(
plexEpisode.LibraryPathId,
path,
cancellationToken),
JellyfinMovie jellyfinMovie => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinMovie.LibraryPathId,
path,
cancellationToken),
JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinEpisode.LibraryPathId,
path,
cancellationToken),
EmbyMovie embyMovie => await _embyPathReplacementService.GetReplacementEmbyPath(
embyMovie.LibraryPathId,
path,
cancellationToken),
EmbyEpisode embyEpisode => await _embyPathReplacementService.GetReplacementEmbyPath(
embyEpisode.LibraryPathId,
path,
cancellationToken),
_ => path
};
}
private DeadAirFallbackResult GetDecoDeadAirFallback(Playout playout, DateTimeOffset now) private DeadAirFallbackResult GetDecoDeadAirFallback(Playout playout, DateTimeOffset now)
{ {
DecoEntries decoEntries = _decoSelector.GetDecoEntries(playout, now); DecoEntries decoEntries = _decoSelector.GetDecoEntries(playout, now);

4
ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs

@ -47,6 +47,8 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
DateTimeOffset.MaxValue, DateTimeOffset.MaxValue,
true, true,
Option<long>.None, Option<long>.None,
Option<int>.None); Option<int>.None,
Option<TimeSpan>.None,
false);
} }
} }

6
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitlesHandler.cs

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@ -7,7 +8,6 @@ using Microsoft.Extensions.Logging;
using Dapper; using Dapper;
using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Subtitles; namespace ErsatzTV.Application.Subtitles;
@ -16,9 +16,9 @@ public class ExtractEmbeddedShowSubtitlesHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILogger<ExtractEmbeddedSubtitlesHandler> logger) ILogger<ExtractEmbeddedSubtitlesHandler> logger)
: ExtractEmbeddedSubtitlesHandlerBase(localFileSystem, logger), : ExtractEmbeddedSubtitlesHandlerBase(fileSystem, logger),
IRequestHandler<ExtractEmbeddedShowSubtitles, Option<BaseError>> IRequestHandler<ExtractEmbeddedShowSubtitles, Option<BaseError>>
{ {
public async Task<Option<BaseError>> Handle( public async Task<Option<BaseError>> Handle(

8
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs

@ -1,9 +1,9 @@
using System.Threading.Channels; using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -23,12 +23,12 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
public ExtractEmbeddedSubtitlesHandler( public ExtractEmbeddedSubtitlesHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
IEntityLocker entityLocker, IEntityLocker entityLocker,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<ExtractEmbeddedSubtitlesHandler> logger) ILogger<ExtractEmbeddedSubtitlesHandler> logger)
: base(localFileSystem, logger) : base(fileSystem, logger)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_entityLocker = entityLocker; _entityLocker = entityLocker;

12
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using CliWrap; using CliWrap;
@ -8,7 +9,6 @@ using Dapper;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -17,7 +17,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Subtitles; namespace ErsatzTV.Application.Subtitles;
[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")]
public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem localFileSystem, ILogger logger) public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem, ILogger logger)
{ {
protected static Task<Validation<BaseError, string>> FFmpegPathMustExist( protected static Task<Validation<BaseError, string>> FFmpegPathMustExist(
TvContext dbContext, TvContext dbContext,
@ -133,7 +133,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
{ {
string fullOutputPath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.OutputPath); string fullOutputPath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.OutputPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath)); Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath));
if (localFileSystem.FileExists(fullOutputPath)) if (fileSystem.File.Exists(fullOutputPath))
{ {
File.Delete(fullOutputPath); File.Delete(fullOutputPath);
} }
@ -193,7 +193,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
} }
string fullOutputPath = Path.Combine(FileSystemLayout.FontsCacheFolder, fontStream.FileName); string fullOutputPath = Path.Combine(FileSystemLayout.FontsCacheFolder, fontStream.FileName);
if (localFileSystem.FileExists(fullOutputPath)) if (fileSystem.File.Exists(fullOutputPath))
{ {
// already extracted // already extracted
continue; continue;
@ -212,7 +212,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
// ffmpeg seems to return exit code 1 in all cases when dumping an attachment // ffmpeg seems to return exit code 1 in all cases when dumping an attachment
// so ignore it and check success a different way // so ignore it and check success a different way
if (localFileSystem.FileExists(fullOutputPath)) if (fileSystem.File.Exists(fullOutputPath))
{ {
logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName); logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName);
} }
@ -300,7 +300,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
{ {
foreach (string path in GetRelativeOutputPath(mediaItemId, subtitle)) foreach (string path in GetRelativeOutputPath(mediaItemId, subtitle))
{ {
return !localFileSystem.FileExists(path); return !fileSystem.File.Exists(path);
} }
return false; return false;

3
ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSample.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Troubleshooting;
public record ArchiveMediaSample(int MediaItemId) : IRequest<Option<string>>;

169
ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSampleHandler.cs

@ -0,0 +1,169 @@
using System.IO.Abstractions;
using System.IO.Compression;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Troubleshooting;
public class ArchiveMediaSampleHandler(
IDbContextFactory<TvContext> dbContextFactory,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
IFileSystem fileSystem,
ILogger<ArchiveMediaSampleHandler> logger)
: TroubleshootingHandlerBase(
plexPathReplacementService,
jellyfinPathReplacementService,
embyPathReplacementService,
fileSystem), IRequestHandler<ArchiveMediaSample, Option<string>>
{
private readonly IFileSystem _fileSystem = fileSystem;
public async Task<Option<string>> Handle(ArchiveMediaSample request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Tuple<MediaItem, string>> validation = await Validate(
dbContext,
request,
cancellationToken);
foreach ((MediaItem mediaItem, string ffmpegPath) in validation.SuccessToSeq())
{
Option<string> maybeMediaSample = await GetMediaSample(
request,
dbContext,
mediaItem,
ffmpegPath,
cancellationToken);
foreach (string mediaSample in maybeMediaSample)
{
return await GetArchive(request, mediaSample, cancellationToken);
}
}
return Option<string>.None;
}
private async Task<Option<string>> GetArchive(
ArchiveMediaSample request,
string mediaSample,
CancellationToken cancellationToken)
{
string tempFile = Path.GetTempFileName();
try
{
await using ZipArchive zipArchive = await ZipFile.OpenAsync(
tempFile,
ZipArchiveMode.Update,
cancellationToken);
string fileName = Path.GetFileName(mediaSample);
await zipArchive.CreateEntryFromFileAsync(mediaSample, fileName, cancellationToken);
return tempFile;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to archive media sample for media item {MediaItemId}", request.MediaItemId);
_fileSystem.File.Delete(tempFile);
}
return Option<string>.None;
}
private async Task<Option<string>> GetMediaSample(
ArchiveMediaSample request,
TvContext dbContext,
MediaItem mediaItem,
string ffmpegPath,
CancellationToken cancellationToken)
{
try
{
string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem, cancellationToken);
if (string.IsNullOrEmpty(mediaItemPath))
{
logger.LogWarning(
"Media item {MediaItemId} does not exist on disk; cannot extract media sample.",
mediaItem.Id);
return Option<string>.None;
}
string extension = Path.GetExtension(mediaItemPath);
if (string.IsNullOrWhiteSpace(extension))
{
// this can help with remote servers (e.g. mediaItemPath is http://localhost/whatever)
extension = Path.GetExtension(await GetLocalPath(mediaItem, cancellationToken));
if (string.IsNullOrWhiteSpace(extension))
{
// fall back to mkv when extension is otherwise unknown
extension = "mkv";
}
}
string tempPath = Path.GetTempPath();
string fileName = Path.ChangeExtension(Guid.NewGuid().ToString(), extension);
string outputPath = Path.Combine(tempPath, fileName);
List<string> arguments =
[
"-nostdin",
"-i", mediaItemPath,
"-t", "30",
"-map", "0",
"-c", "copy",
outputPath
];
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
using var linkedTokenSource =
CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
logger.LogDebug("media sample arguments {Arguments}", arguments);
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithArguments(arguments)
.WithWorkingDirectory(FileSystemLayout.FontsCacheFolder)
.WithStandardErrorPipe(PipeTarget.Null)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(linkedTokenSource.Token);
if (result.IsSuccess)
{
return outputPath;
}
logger.LogWarning(
"Failed to extract media sample for media item {MediaItemId} - exit code {ExitCode}",
request.MediaItemId,
result.ExitCode);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to extract media sample for media item {MediaItemId}", request.MediaItemId);
}
return Option<string>.None;
}
private static async Task<Validation<BaseError, Tuple<MediaItem, string>>> Validate(
TvContext dbContext,
ArchiveMediaSample request,
CancellationToken cancellationToken) =>
(await MediaItemMustExist(dbContext, request.MediaItemId, cancellationToken),
await FFmpegPathMustExist(dbContext, cancellationToken))
.Apply((mediaItem, ffmpegPath) => Tuple(mediaItem, ffmpegPath));
}

199
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -1,9 +1,8 @@
using Dapper; using System.IO.Abstractions;
using ErsatzTV.Application.Streaming; using ErsatzTV.Application.Streaming;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
@ -30,6 +29,7 @@ public class PrepareTroubleshootingPlaybackHandler(
IJellyfinPathReplacementService jellyfinPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService, IEmbyPathReplacementService embyPathReplacementService,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ISongVideoGenerator songVideoGenerator, ISongVideoGenerator songVideoGenerator,
IWatermarkSelector watermarkSelector, IWatermarkSelector watermarkSelector,
@ -37,7 +37,11 @@ public class PrepareTroubleshootingPlaybackHandler(
IMediator mediator, IMediator mediator,
LoggingLevelSwitches loggingLevelSwitches, LoggingLevelSwitches loggingLevelSwitches,
ILogger<PrepareTroubleshootingPlaybackHandler> logger) ILogger<PrepareTroubleshootingPlaybackHandler> logger)
: IRequestHandler<PrepareTroubleshootingPlayback, Either<BaseError, PlayoutItemResult>> : TroubleshootingHandlerBase(
plexPathReplacementService,
jellyfinPathReplacementService,
embyPathReplacementService,
fileSystem), IRequestHandler<PrepareTroubleshootingPlayback, Either<BaseError, PlayoutItemResult>>
{ {
public async Task<Either<BaseError, PlayoutItemResult>> Handle( public async Task<Either<BaseError, PlayoutItemResult>> Handle(
PrepareTroubleshootingPlayback request, PrepareTroubleshootingPlayback request,
@ -85,7 +89,7 @@ public class PrepareTroubleshootingPlaybackHandler(
HlsRealtime: false, HlsRealtime: false,
start, start,
TimeSpan.Zero, TimeSpan.Zero,
TargetFramerate: Option<int>.None, TargetFramerate: Option<FrameRate>.None,
IsTroubleshooting: true, IsTroubleshooting: true,
request.FFmpegProfileId), request.FFmpegProfileId),
cancellationToken); cancellationToken);
@ -103,7 +107,10 @@ public class PrepareTroubleshootingPlaybackHandler(
entityLocker.UnlockTroubleshootingPlayback(); entityLocker.UnlockTroubleshootingPlayback();
} }
return result.Map(model => new PlayoutItemResult(model.Process, model.GraphicsEngineContext, model.MediaItemId)); return result.Map(model => new PlayoutItemResult(
model.Process,
model.GraphicsEngineContext,
model.MediaItemId));
} }
if (maybeChannel.IsNone) if (maybeChannel.IsNone)
@ -316,7 +323,7 @@ public class PrepareTroubleshootingPlaybackHandler(
inPoint, inPoint,
channelStartTime: DateTimeOffset.Now, channelStartTime: DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
Option<int>.None, Option<FrameRate>.None,
FileSystemLayout.TranscodeTroubleshootingFolder, FileSystemLayout.TranscodeTroubleshootingFolder,
_ => { }, _ => { },
canProxy: true, canProxy: true,
@ -373,80 +380,13 @@ public class PrepareTroubleshootingPlaybackHandler(
TvContext dbContext, TvContext dbContext,
PrepareTroubleshootingPlayback request, PrepareTroubleshootingPlayback request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
(await MediaItemMustExist(dbContext, request, cancellationToken), (await MediaItemMustExist(dbContext, request.MediaItemId, cancellationToken),
await FFmpegPathMustExist(dbContext, cancellationToken), await FFmpegPathMustExist(dbContext, cancellationToken),
await FFprobePathMustExist(dbContext, cancellationToken), await FFprobePathMustExist(dbContext, cancellationToken),
await FFmpegProfileMustExist(dbContext, request, cancellationToken)) await FFmpegProfileMustExist(dbContext, request, cancellationToken))
.Apply((mediaItem, ffmpegPath, ffprobePath, ffmpegProfile) => .Apply((mediaItem, ffmpegPath, ffprobePath, ffmpegProfile) =>
Tuple(mediaItem, ffmpegPath, ffprobePath, ffmpegProfile)); Tuple(mediaItem, ffmpegPath, ffprobePath, ffmpegProfile));
private static async Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
TvContext dbContext,
PrepareTroubleshootingPlayback request,
CancellationToken cancellationToken) =>
await dbContext.MediaItems
.AsNoTracking()
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Subtitles)
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Subtitles)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Subtitles)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as MusicVideo).Artist)
.ThenInclude(mv => mv.ArtistMetadata)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Subtitles)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(ov => ov.MediaFiles)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(ov => ov.Streams)
.Include(mi => (mi as Song).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Song).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Song).SongMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Image).ImageMetadata)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
.SelectOneAsync(mi => mi.Id, mi => mi.Id == request.MediaItemId, cancellationToken)
.Map(o => o.ToValidation<BaseError>(new UnableToLocatePlayoutItem()));
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(
TvContext dbContext,
CancellationToken cancellationToken) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
private static Task<Validation<BaseError, string>> FFprobePathMustExist( private static Task<Validation<BaseError, string>> FFprobePathMustExist(
TvContext dbContext, TvContext dbContext,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
@ -462,115 +402,4 @@ public class PrepareTroubleshootingPlaybackHandler(
.Include(p => p.Resolution) .Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken) .SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist")); .Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
private async Task<string> GetMediaItemPath(
TvContext dbContext,
MediaItem mediaItem,
CancellationToken cancellationToken)
{
string path = await GetLocalPath(mediaItem, cancellationToken);
// check filesystem first
if (localFileSystem.FileExists(path))
{
if (mediaItem is RemoteStream remoteStream)
{
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
? remoteStream.Url
: $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}";
}
return path;
}
// attempt to remotely stream plex
MediaFile file = mediaItem.GetHeadVersion().MediaFiles.Head();
switch (file)
{
case PlexMediaFile pmf:
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>(
@"SELECT PMS.Id FROM PlexMediaSource PMS
INNER JOIN Library L on PMS.Id = L.MediaSourceId
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId
WHERE LP.Id = @LibraryPathId",
new { mediaItem.LibraryPathId })
.Map(Optional);
foreach (int plexMediaSourceId in maybeId)
{
logger.LogDebug(
"Attempting to stream Plex file {PlexFileName} using key {PlexKey}",
pmf.Path,
pmf.Key);
return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
}
break;
}
// attempt to remotely stream jellyfin
Option<string> jellyfinItemId = mediaItem switch
{
JellyfinEpisode e => e.ItemId,
JellyfinMovie m => m.ItemId,
_ => None
};
foreach (string itemId in jellyfinItemId)
{
return $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}";
}
// attempt to remotely stream emby
Option<string> embyItemId = mediaItem switch
{
EmbyEpisode e => e.ItemId,
EmbyMovie m => m.ItemId,
_ => None
};
foreach (string itemId in embyItemId)
{
return $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}";
}
return null;
}
private async Task<string> GetLocalPath(MediaItem mediaItem, CancellationToken cancellationToken)
{
MediaVersion version = mediaItem.GetHeadVersion();
MediaFile file = version.MediaFiles.Head();
string path = file.Path;
return mediaItem switch
{
PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath(
plexMovie.LibraryPathId,
path,
cancellationToken),
PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath(
plexEpisode.LibraryPathId,
path,
cancellationToken),
JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinMovie.LibraryPathId,
path,
cancellationToken),
JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinEpisode.LibraryPathId,
path,
cancellationToken),
EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath(
embyMovie.LibraryPathId,
path,
cancellationToken),
EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath(
embyEpisode.LibraryPathId,
path,
cancellationToken),
_ => path
};
}
} }

3
ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs

@ -120,7 +120,8 @@ public partial class StartTroubleshootingPlaybackHandler(
try try
{ {
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token);
Command processWithPipe = request.PlayoutItemResult.Process; Command processWithPipe = request.PlayoutItemResult.Process;
foreach (GraphicsEngineContext graphicsEngineContext in request.PlayoutItemResult.GraphicsEngineContext) foreach (GraphicsEngineContext graphicsEngineContext in request.PlayoutItemResult.GraphicsEngineContext)

167
ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs

@ -0,0 +1,167 @@
using System.IO.Abstractions;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Troubleshooting;
public abstract class TroubleshootingHandlerBase(
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
IFileSystem fileSystem)
{
protected static async Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
TvContext dbContext,
int mediaItemId,
CancellationToken cancellationToken) =>
await dbContext.MediaItems
.AsNoTracking()
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Subtitles)
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Subtitles)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Subtitles)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as MusicVideo).Artist)
.ThenInclude(mv => mv.ArtistMetadata)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Subtitles)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(ov => ov.MediaFiles)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(ov => ov.Streams)
.Include(mi => (mi as Song).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Song).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Song).SongMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Image).ImageMetadata)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
.AsSplitQuery()
.SingleOrDefaultAsync(mi => mi.Id == mediaItemId, cancellationToken)
.Map(Optional)
.Map(o => o.ToValidation<BaseError>(new UnableToLocatePlayoutItem()));
protected static Task<Validation<BaseError, string>> FFmpegPathMustExist(
TvContext dbContext,
CancellationToken cancellationToken) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
protected Task<string> GetLocalPath(MediaItem mediaItem, CancellationToken cancellationToken) =>
mediaItem.GetLocalPath(
plexPathReplacementService,
jellyfinPathReplacementService,
embyPathReplacementService,
cancellationToken);
protected async Task<string> GetMediaItemPath(
TvContext dbContext,
MediaItem mediaItem,
CancellationToken cancellationToken)
{
string path = await GetLocalPath(mediaItem, cancellationToken);
// check filesystem first
if (fileSystem.File.Exists(path))
{
if (mediaItem is RemoteStream remoteStream)
{
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
? remoteStream.Url
: $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}";
}
return path;
}
// attempt to remotely stream plex
MediaFile file = mediaItem.GetHeadVersion().MediaFiles.Head();
switch (file)
{
case PlexMediaFile pmf:
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>(
@"SELECT PMS.Id FROM PlexMediaSource PMS
INNER JOIN Library L on PMS.Id = L.MediaSourceId
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId
WHERE LP.Id = @LibraryPathId",
new { mediaItem.LibraryPathId })
.Map(Optional);
foreach (int plexMediaSourceId in maybeId)
{
return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
}
break;
}
// attempt to remotely stream jellyfin
Option<string> jellyfinItemId = mediaItem switch
{
JellyfinEpisode e => e.ItemId,
JellyfinMovie m => m.ItemId,
_ => None
};
foreach (string itemId in jellyfinItemId)
{
return $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}";
}
// attempt to remotely stream emby
Option<string> embyItemId = mediaItem switch
{
EmbyEpisode e => e.ItemId,
EmbyMovie m => m.ItemId,
_ => None
};
foreach (string itemId in embyItemId)
{
return $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}";
}
return null;
}
}

6
ErsatzTV.Core/Api/ScriptedPlayout/ContentAll.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentAll.cs

@ -5,13 +5,13 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentAll public record ContentAll
{ {
[Description("The 'key' for the content that should be added")] [Description("The 'key' for the content that should be added")]
public string Content { get; set; } public required string Content { get; set; }
[Description("Flags this content as filler, which influences EPG grouping")] [Description("Flags this content as filler, which influences EPG grouping")]
public string FillerKind { get; set; } public string? FillerKind { get; set; }
[Description("Overrides the title used in the EPG")] [Description("Overrides the title used in the EPG")]
public string CustomTitle { get; set; } public string? CustomTitle { get; set; }
public bool DisableWatermarks { get; set; } public bool DisableWatermarks { get; set; }
} }

5
ErsatzTV.Core/Api/ScriptedPlayout/ContentCollection.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentCollection.cs

@ -1,4 +1,5 @@
using System.ComponentModel; using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.Core.Api.ScriptedPlayout; namespace ErsatzTV.Core.Api.ScriptedPlayout;
@ -7,10 +8,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentCollection public record ContentCollection
{ {
[Description("Unique name used to reference this content throughout the scripted schedule")] [Description("Unique name used to reference this content throughout the scripted schedule")]
public string Key { get; init; } public required string Key { get; init; }
[Description("The name of the existing manual collection")] [Description("The name of the existing manual collection")]
public string Collection { get; init; } public required string Collection { get; init; }
[Description("The playback order; only chronological and shuffle are currently supported")] [Description("The playback order; only chronological and shuffle are currently supported")]
public string Order { get; init; } = "shuffle"; public string Order { get; init; } = "shuffle";

4
ErsatzTV.Core/Api/ScriptedPlayout/ContentCreatePlaylist.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentCreatePlaylist.cs

@ -5,8 +5,8 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentCreatePlaylist public record ContentCreatePlaylist
{ {
[Description("Unique name used to reference this content throughout the scripted schedule")] [Description("Unique name used to reference this content throughout the scripted schedule")]
public string Key { get; set; } public required string Key { get; set; }
[Description("List of playlist items")] [Description("List of playlist items")]
public List<PlaylistItem> Items { get; set; } public required List<PlaylistItem> Items { get; set; }
} }

4
ErsatzTV.Core/Api/ScriptedPlayout/ContentMarathon.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentMarathon.cs

@ -5,11 +5,11 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentMarathon public record ContentMarathon
{ {
[Description("Unique name used to reference this content throughout the scripted schedule")] [Description("Unique name used to reference this content throughout the scripted schedule")]
public string Key { get; set; } public required string Key { get; set; }
[Description( [Description(
"Tells the scheduler how to group the combined content (returned from all guids and searches). Valid values are show, season, artist and album.")] "Tells the scheduler how to group the combined content (returned from all guids and searches). Valid values are show, season, artist and album.")]
public string GroupBy { get; set; } public required string GroupBy { get; set; }
[Description("Playback order within each group; only chronological and shuffle are currently supported")] [Description("Playback order within each group; only chronological and shuffle are currently supported")]
public string ItemOrder { get; set; } = "shuffle"; public string ItemOrder { get; set; } = "shuffle";

4
ErsatzTV.Core/Api/ScriptedPlayout/ContentMultiCollection.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentMultiCollection.cs

@ -7,10 +7,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentMultiCollection public record ContentMultiCollection
{ {
[Description("Unique name used to reference this content throughout the scripted schedule")] [Description("Unique name used to reference this content throughout the scripted schedule")]
public string Key { get; set; } public required string Key { get; set; }
[Description("The name of the existing multi-collection")] [Description("The name of the existing multi-collection")]
public string MultiCollection { get; set; } public required string MultiCollection { get; set; }
[Description("The playback order; only chronological and shuffle are currently supported")] [Description("The playback order; only chronological and shuffle are currently supported")]
public string Order { get; set; } = "shuffle"; public string Order { get; set; } = "shuffle";

6
ErsatzTV.Core/Api/ScriptedPlayout/ContentPlaylist.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentPlaylist.cs

@ -5,11 +5,11 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentPlaylist public record ContentPlaylist
{ {
[Description("Unique name used to reference this content throughout the scripted schedule")] [Description("Unique name used to reference this content throughout the scripted schedule")]
public string Key { get; set; } public required string Key { get; set; }
[Description("The name of the existing playlist")] [Description("The name of the existing playlist")]
public string Playlist { get; set; } public required string Playlist { get; set; }
[Description("The name of the existing playlist group that contains the named playlist")] [Description("The name of the existing playlist group that contains the named playlist")]
public string PlaylistGroup { get; set; } public required string PlaylistGroup { get; set; }
} }

15
ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentSearch.cs

@ -0,0 +1,15 @@
using System.ComponentModel;
namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentSearch
{
[Description("Unique name used to reference this content throughout the scripted schedule")]
public required string Key { get; set; }
[Description("The search query")]
public required string Query { get; set; }
[Description("The playback order; only chronological and shuffle are currently supported")]
public string Order { get; set; } = "shuffle";
}

4
ErsatzTV.Core/Api/ScriptedPlayout/ContentShow.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentShow.cs

@ -5,10 +5,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentShow public record ContentShow
{ {
[Description("Unique name used to reference this content throughout the scripted schedule")] [Description("Unique name used to reference this content throughout the scripted schedule")]
public string Key { get; set; } public required string Key { get; set; }
[Description("List of show identifiers")] [Description("List of show identifiers")]
public Dictionary<string, string> Guids { get; set; } = []; public required Dictionary<string, string> Guids { get; set; } = [];
[Description("The playback order; only chronological and shuffle are currently supported")] [Description("The playback order; only chronological and shuffle are currently supported")]
public string Order { get; set; } = "shuffle"; public string Order { get; set; } = "shuffle";

4
ErsatzTV.Core/Api/ScriptedPlayout/ContentSmartCollection.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentSmartCollection.cs

@ -7,10 +7,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentSmartCollection public record ContentSmartCollection
{ {
[Description("Unique name used to reference this content throughout the scripted schedule")] [Description("Unique name used to reference this content throughout the scripted schedule")]
public string Key { get; set; } public required string Key { get; set; }
[Description("The name of the existing smart collection")] [Description("The name of the existing smart collection")]
public string SmartCollection { get; set; } public required string SmartCollection { get; set; }
[Description("The playback order; only chronological and shuffle are currently supported")] [Description("The playback order; only chronological and shuffle are currently supported")]
public string Order { get; set; } = "shuffle"; public string Order { get; set; } = "shuffle";

0
ErsatzTV.Core/Api/ScriptedPlayout/ControlGraphicsOff.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlGraphicsOff.cs

2
ErsatzTV.Core/Api/ScriptedPlayout/ControlGraphicsOn.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlGraphicsOn.cs

@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlGraphicsOn public record ControlGraphicsOn
{ {
[Description("A list of graphics elements to turn on.")] [Description("A list of graphics elements to turn on.")]
public List<string> Graphics { get; set; } public required List<string> Graphics { get; set; }
public Dictionary<string, string> Variables { get; set; } = []; public Dictionary<string, string> Variables { get; set; } = [];
} }

2
ErsatzTV.Core/Api/ScriptedPlayout/ControlPreRollOn.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlPreRollOn.cs

@ -5,5 +5,5 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlPreRollOn public record ControlPreRollOn
{ {
[Description("The 'key' for the scripted playlist")] [Description("The 'key' for the scripted playlist")]
public string Playlist { get; set; } public required string Playlist { get; set; }
} }

4
ErsatzTV.Core/Api/ScriptedPlayout/ControlSkipItems.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlSkipItems.cs

@ -5,8 +5,8 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlSkipItems public record ControlSkipItems
{ {
[Description("The 'key' for the content")] [Description("The 'key' for the content")]
public string Content { get; set; } public required string Content { get; set; }
[Description("The number of items to skip")] [Description("The number of items to skip")]
public int Count { get; set; } public required int Count { get; set; }
} }

6
ErsatzTV.Core/Api/ScriptedPlayout/ControlSkipToItem.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlSkipToItem.cs

@ -5,11 +5,11 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlSkipToItem public record ControlSkipToItem
{ {
[Description("The 'key' for the content")] [Description("The 'key' for the content")]
public string Content { get; set; } public required string Content { get; set; }
[Description("The season number")] [Description("The season number")]
public int Season { get; set; } public required int Season { get; set; }
[Description("The episode number")] [Description("The episode number")]
public int Episode { get; set; } public required int Episode { get; set; }
} }

2
ErsatzTV.Core/Api/ScriptedPlayout/ControlStartEpgGroup.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlStartEpgGroup.cs

@ -8,5 +8,5 @@ public record ControlStartEpgGroup
public bool Advance { get; set; } = true; public bool Advance { get; set; } = true;
[Description("Custom title to apply to all items in the EPG group.")] [Description("Custom title to apply to all items in the EPG group.")]
public string CustomTitle { get; set; } public string? CustomTitle { get; set; }
} }

2
ErsatzTV.Core/Api/ScriptedPlayout/ControlWaitUntil.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlWaitUntil.cs

@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlWaitUntil public record ControlWaitUntil
{ {
[Description("The time of day to wait (insert unscheduled time) until")] [Description("The time of day to wait (insert unscheduled time) until")]
public string When { get; set; } public required string When { get; set; }
[Description("When true, will wait until the specified time tomorrow if it has already passed today.")] [Description("When true, will wait until the specified time tomorrow if it has already passed today.")]
public bool Tomorrow { get; set; } public bool Tomorrow { get; set; }

2
ErsatzTV.Core/Api/ScriptedPlayout/ControlWaitUntilExact.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlWaitUntilExact.cs

@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlWaitUntilExact public record ControlWaitUntilExact
{ {
[Description("The time to wait (insert unscheduled time) until")] [Description("The time to wait (insert unscheduled time) until")]
public DateTimeOffset When { get; set; } public required DateTimeOffset When { get; set; }
[Description("When true, the current time of the playout build is allowed to move backward when the playout is reset.")] [Description("When true, the current time of the playout build is allowed to move backward when the playout is reset.")]
public bool RewindOnReset { get; set; } public bool RewindOnReset { get; set; }

0
ErsatzTV.Core/Api/ScriptedPlayout/ControlWatermarkOff.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlWatermarkOff.cs

2
ErsatzTV.Core/Api/ScriptedPlayout/ControlWatermarkOn.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ControlWatermarkOn.cs

@ -5,5 +5,5 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlWatermarkOn public record ControlWatermarkOn
{ {
[Description("A list of existing watermark names to turn on")] [Description("A list of existing watermark names to turn on")]
public List<string> Watermark { get; set; } public required List<string> Watermark { get; set; }
} }

4
ErsatzTV.Core/Api/ScriptedPlayout/PeekItemDuration.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PeekItemDuration.cs

@ -4,8 +4,8 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public class PeekItemDuration public class PeekItemDuration
{ {
public string Content { get; set; } public required string Content { get; set; }
[Description("Duration in milliseconds")] [Description("Duration in milliseconds")]
public long Milliseconds { get; set; } public required long Milliseconds { get; set; }
} }

4
ErsatzTV.Core/Api/ScriptedPlayout/PlaylistItem.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlaylistItem.cs

@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlaylistItem public record PlaylistItem
{ {
[Description("The 'key' for the content")] [Description("The 'key' for the content")]
public string Content { get; set; } public required string Content { get; set; }
public int Count { get; set; } public required int Count { get; set; }
} }

8
ErsatzTV.Core/Api/ScriptedPlayout/PlayoutContext.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutContext.cs

@ -5,14 +5,14 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutContext public record PlayoutContext
{ {
[Description("The current time of the playout build")] [Description("The current time of the playout build")]
public DateTimeOffset CurrentTime { get; set; } public required DateTimeOffset CurrentTime { get; set; }
[Description("The start time of the playout build")] [Description("The start time of the playout build")]
public DateTimeOffset StartTime { get; set; } public required DateTimeOffset StartTime { get; set; }
[Description("The finish time of the playout build")] [Description("The finish time of the playout build")]
public DateTimeOffset FinishTime { get; set; } public required DateTimeOffset FinishTime { get; set; }
[Description("Indicates whether the current playout build is complete")] [Description("Indicates whether the current playout build is complete")]
public bool IsDone { get; set; } public required bool IsDone { get; set; }
} }

8
ErsatzTV.Core/Api/ScriptedPlayout/PlayoutCount.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutCount.cs

@ -5,15 +5,15 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutCount public record PlayoutCount
{ {
[Description("The 'key' for the content that should be added")] [Description("The 'key' for the content that should be added")]
public string Content { get; set; } public required string Content { get; set; }
public int Count { get; set; } public required int Count { get; set; }
[Description("Flags this content as filler, which influences EPG grouping")] [Description("Flags this content as filler, which influences EPG grouping")]
public string FillerKind { get; set; } public string? FillerKind { get; set; }
[Description("Overrides the title used in the EPG")] [Description("Overrides the title used in the EPG")]
public string CustomTitle { get; set; } public string? CustomTitle { get; set; }
public bool DisableWatermarks { get; set; } public bool DisableWatermarks { get; set; }
} }

10
ErsatzTV.Core/Api/ScriptedPlayout/PlayoutDuration.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutDuration.cs

@ -5,14 +5,14 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutDuration public record PlayoutDuration
{ {
[Description("The 'key' for the content that should be added")] [Description("The 'key' for the content that should be added")]
public string Content { get; set; } public required string Content { get; set; }
[Description("The amount of time to add using the referenced content")] [Description("The amount of time to add using the referenced content")]
public string Duration { get; set; } public required string Duration { get; set; }
[Description( [Description(
"The 'key' for the content that should be used to fill any remaining unscheduled time. One item will be selected to be looped and trimmed to exactly fit.")] "The 'key' for the content that should be used to fill any remaining unscheduled time. One item will be selected to be looped and trimmed to exactly fit.")]
public string Fallback { get; set; } public string? Fallback { get; set; }
[Description("Controls whether content will be trimmed to exactly fit the specified duration")] [Description("Controls whether content will be trimmed to exactly fit the specified duration")]
public bool Trim { get; set; } public bool Trim { get; set; }
@ -29,10 +29,10 @@ public record PlayoutDuration
public bool OfflineTail { get; set; } public bool OfflineTail { get; set; }
[Description("Flags this content as filler, which influences EPG grouping")] [Description("Flags this content as filler, which influences EPG grouping")]
public string FillerKind { get; set; } public string? FillerKind { get; set; }
[Description("Overrides the title used in the EPG")] [Description("Overrides the title used in the EPG")]
public string CustomTitle { get; set; } public string? CustomTitle { get; set; }
public bool DisableWatermarks { get; set; } public bool DisableWatermarks { get; set; }
} }

10
ErsatzTV.Core/Api/ScriptedPlayout/PlayoutPadToNext.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutPadToNext.cs

@ -5,14 +5,14 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutPadToNext public record PlayoutPadToNext
{ {
[Description("The 'key' for the content that should be added")] [Description("The 'key' for the content that should be added")]
public string Content { get; set; } public required string Content { get; set; }
[Description("The minutes interval")] [Description("The minutes interval")]
public int Minutes { get; set; } public required int Minutes { get; set; }
[Description( [Description(
"The 'key' for the content that should be used to fill any remaining unscheduled time. One item will be selected to be looped and trimmed to exactly fit.")] "The 'key' for the content that should be used to fill any remaining unscheduled time. One item will be selected to be looped and trimmed to exactly fit.")]
public string Fallback { get; set; } public string? Fallback { get; set; }
[Description("Controls whether content will be trimmed to exactly fit the specified interval")] [Description("Controls whether content will be trimmed to exactly fit the specified interval")]
public bool Trim { get; set; } public bool Trim { get; set; }
@ -29,10 +29,10 @@ public record PlayoutPadToNext
public bool OfflineTail { get; set; } = true; public bool OfflineTail { get; set; } = true;
[Description("Flags this content as filler, which influences EPG grouping")] [Description("Flags this content as filler, which influences EPG grouping")]
public string FillerKind { get; set; } public string? FillerKind { get; set; }
[Description("Overrides the title used in the EPG")] [Description("Overrides the title used in the EPG")]
public string CustomTitle { get; set; } public string? CustomTitle { get; set; }
public bool DisableWatermarks { get; set; } public bool DisableWatermarks { get; set; }
} }

10
ErsatzTV.Core/Api/ScriptedPlayout/PlayoutPadUntil.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutPadUntil.cs

@ -5,10 +5,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutPadUntil public record PlayoutPadUntil
{ {
[Description("The 'key' for the content that should be added")] [Description("The 'key' for the content that should be added")]
public string Content { get; set; } public required string Content { get; set; }
[Description("The time of day that content should be added until")] [Description("The time of day that content should be added until")]
public string When { get; set; } public required string When { get; set; }
[Description( [Description(
"Only used when the current playout time is already after the specified pad until time. When true, content will be scheduled until the specified time of day (the next day). When false, no content will be scheduled by this request.")] "Only used when the current playout time is already after the specified pad until time. When true, content will be scheduled until the specified time of day (the next day). When false, no content will be scheduled by this request.")]
@ -16,7 +16,7 @@ public record PlayoutPadUntil
[Description( [Description(
"The 'key' for the content that should be used to fill any remaining unscheduled time. One item will be selected to be looped and trimmed to exactly fit.")] "The 'key' for the content that should be used to fill any remaining unscheduled time. One item will be selected to be looped and trimmed to exactly fit.")]
public string Fallback { get; set; } public string? Fallback { get; set; }
[Description("Controls whether content will be trimmed to exactly fit until the specified time")] [Description("Controls whether content will be trimmed to exactly fit until the specified time")]
public bool Trim { get; set; } public bool Trim { get; set; }
@ -34,10 +34,10 @@ public record PlayoutPadUntil
public bool OfflineTail { get; set; } public bool OfflineTail { get; set; }
[Description("Flags this content as filler, which influences EPG grouping")] [Description("Flags this content as filler, which influences EPG grouping")]
public string FillerKind { get; set; } public string? FillerKind { get; set; }
[Description("Overrides the title used in the EPG")] [Description("Overrides the title used in the EPG")]
public string CustomTitle { get; set; } public string? CustomTitle { get; set; }
public bool DisableWatermarks { get; set; } public bool DisableWatermarks { get; set; }
} }

10
ErsatzTV.Core/Api/ScriptedPlayout/PlayoutPadUntilExact.cs → ErsatzTV.Core.Nullable/Api/ScriptedPlayout/PlayoutPadUntilExact.cs

@ -5,14 +5,14 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutPadUntilExact public record PlayoutPadUntilExact
{ {
[Description("The 'key' for the content that should be added")] [Description("The 'key' for the content that should be added")]
public string Content { get; set; } public required string Content { get; set; }
[Description("The time content should be added until")] [Description("The time content should be added until")]
public DateTimeOffset When { get; set; } public required DateTimeOffset When { get; set; }
[Description( [Description(
"The 'key' for the content that should be used to fill any remaining unscheduled time. One item will be selected to be looped and trimmed to exactly fit.")] "The 'key' for the content that should be used to fill any remaining unscheduled time. One item will be selected to be looped and trimmed to exactly fit.")]
public string Fallback { get; set; } public string? Fallback { get; set; }
[Description("Controls whether content will be trimmed to exactly fit until the specified time")] [Description("Controls whether content will be trimmed to exactly fit until the specified time")]
public bool Trim { get; set; } public bool Trim { get; set; }
@ -30,10 +30,10 @@ public record PlayoutPadUntilExact
public bool OfflineTail { get; set; } public bool OfflineTail { get; set; }
[Description("Flags this content as filler, which influences EPG grouping")] [Description("Flags this content as filler, which influences EPG grouping")]
public string FillerKind { get; set; } public string? FillerKind { get; set; }
[Description("Overrides the title used in the EPG")] [Description("Overrides the title used in the EPG")]
public string CustomTitle { get; set; } public string? CustomTitle { get; set; }
public bool DisableWatermarks { get; set; } public bool DisableWatermarks { get; set; }
} }

14
ErsatzTV.Core.Nullable/ErsatzTV.Core.Nullable.csproj

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ErsatzTV.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Folder Include="Api\" />
</ItemGroup>
</Project>

25
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -1,32 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" /> <PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.9.0" /> <PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" /> <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" /> <PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" /> <PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
<PackageReference Include="Serilog" Version="4.3.0" /> <PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.2" /> <PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" /> <PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

177
ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs

@ -2,11 +2,11 @@ using Destructurama;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Tests.Fakes;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NUnit.Framework; using NUnit.Framework;
using Serilog; using Serilog;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg; namespace ErsatzTV.Core.Tests.FFmpeg;
@ -94,9 +94,10 @@ public class CustomStreamSelectorTests
- "eng" - "eng"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -123,9 +124,10 @@ public class CustomStreamSelectorTests
- audio_language: ["und"] - audio_language: ["und"]
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -152,9 +154,10 @@ public class CustomStreamSelectorTests
- audio_language: ["en", "eng"] - audio_language: ["en", "eng"]
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -187,9 +190,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -217,9 +221,10 @@ public class CustomStreamSelectorTests
- "en*" - "en*"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -248,9 +253,10 @@ public class CustomStreamSelectorTests
"""; """;
_audioVersion = GetTestAudioVersion("en"); _audioVersion = GetTestAudioVersion("en");
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -279,9 +285,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -305,9 +312,10 @@ public class CustomStreamSelectorTests
- "eng" - "eng"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -337,9 +345,10 @@ public class CustomStreamSelectorTests
- "en*" - "en*"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -378,9 +387,10 @@ public class CustomStreamSelectorTests
} }
]; ];
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -413,9 +423,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -450,9 +461,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -492,9 +504,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -528,9 +541,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -568,9 +582,10 @@ public class CustomStreamSelectorTests
- "riff" - "riff"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -600,9 +615,10 @@ public class CustomStreamSelectorTests
- "movie" - "movie"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -634,9 +650,10 @@ public class CustomStreamSelectorTests
- "signs" - "signs"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -668,9 +685,10 @@ public class CustomStreamSelectorTests
- "songs" - "songs"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -699,9 +717,10 @@ public class CustomStreamSelectorTests
subtitle_condition: "forced" subtitle_condition: "forced"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -730,9 +749,10 @@ public class CustomStreamSelectorTests
subtitle_condition: "lang like 'en%' and external" subtitle_condition: "lang like 'en%' and external"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -761,9 +781,10 @@ public class CustomStreamSelectorTests
audio_condition: "title like '%movie%'" audio_condition: "title like '%movie%'"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -792,9 +813,10 @@ public class CustomStreamSelectorTests
audio_condition: "channels > 2" audio_condition: "channels > 2"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -822,9 +844,10 @@ public class CustomStreamSelectorTests
audio_title_blocklist: ["riff"] audio_title_blocklist: ["riff"]
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -853,9 +876,10 @@ public class CustomStreamSelectorTests
subtitle_language: ["jp","en*"] subtitle_language: ["jp","en*"]
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -885,9 +909,10 @@ public class CustomStreamSelectorTests
subtitle_language: ["es*","de*"] subtitle_language: ["es*","de*"]
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,

7
ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs

@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg; namespace ErsatzTV.Core.Tests.FFmpeg;
@ -61,7 +62,7 @@ public class FFmpegStreamSelectorTests
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()), new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(), Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(), Substitute.For<IConfigElementRepository>(),
Substitute.For<ILocalFileSystem>(), new MockFileSystem(),
languageCodeService, languageCodeService,
Substitute.For<ILogger<FFmpegStreamSelector>>()); Substitute.For<ILogger<FFmpegStreamSelector>>());
@ -123,7 +124,7 @@ public class FFmpegStreamSelectorTests
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()), new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(), Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(), Substitute.For<IConfigElementRepository>(),
Substitute.For<ILocalFileSystem>(), new MockFileSystem(),
languageCodeService, languageCodeService,
Substitute.For<ILogger<FFmpegStreamSelector>>()); Substitute.For<ILogger<FFmpegStreamSelector>>());
@ -173,7 +174,7 @@ public class FFmpegStreamSelectorTests
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()), new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(), Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(), Substitute.For<IConfigElementRepository>(),
Substitute.For<ILocalFileSystem>(), new MockFileSystem(),
languageCodeService, languageCodeService,
Substitute.For<ILogger<FFmpegStreamSelector>>()); Substitute.For<ILogger<FFmpegStreamSelector>>());

8
ErsatzTV.Core.Tests/Fakes/FakeFileEntry.cs

@ -1,8 +0,0 @@
namespace ErsatzTV.Core.Tests.Fakes;
public record FakeFileEntry(string Path)
{
public DateTime LastWriteTime { get; set; } = SystemTime.MinValueUtc;
public string Contents { get; set; }
}

3
ErsatzTV.Core.Tests/Fakes/FakeFolderEntry.cs

@ -1,3 +0,0 @@
namespace ErsatzTV.Core.Tests.Fakes;
public record FakeFolderEntry(string Path);

99
ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs

@ -1,99 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Core.Tests.Fakes;
public class FakeLocalFileSystem : ILocalFileSystem
{
private readonly List<FakeFileEntry> _files;
private readonly List<FakeFolderEntry> _folders;
public FakeLocalFileSystem(List<FakeFileEntry> files) : this(files, new List<FakeFolderEntry>())
{
}
public FakeLocalFileSystem(List<FakeFileEntry> files, List<FakeFolderEntry> folders)
{
_files = files;
var allFolders = new List<string>(folders.Map(f => f.Path));
foreach (FakeFileEntry file in _files)
{
List<DirectoryInfo> moreFolders =
Split(new DirectoryInfo(Path.GetDirectoryName(file.Path) ?? string.Empty));
allFolders.AddRange(moreFolders.Map(i => i.FullName));
}
_folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList();
}
public Unit EnsureFolderExists(string folder) => Unit.Default;
public DateTime GetLastWriteTime(string path) =>
Optional(_files.SingleOrDefault(f => f.Path == path))
.Map(f => f.LastWriteTime)
.IfNone(SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_folders.Any(f => f.Path == libraryPath.Path);
public IEnumerable<string> ListSubdirectories(string folder) =>
_folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder);
public IEnumerable<string> ListFiles(string folder) =>
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
// TODO: this isn't accurate, need to use search pattern
public IEnumerable<string> ListFiles(string folder, string searchPattern) =>
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public IEnumerable<string> ListFiles(string folder, params string[] searchPatterns) =>
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public bool FileExists(string path) => _files.Any(f => f.Path == path);
public bool FolderExists(string folder) => false;
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
Task.FromResult(Right<BaseError, Unit>(Unit.Default));
public Unit EmptyFolder(string folder) => Unit.Default;
public async Task<string> ReadAllText(string path) => await _files
.Filter(f => f.Path == path)
.HeadOrNone()
.Select(f => f.Contents)
.IfNoneAsync(string.Empty);
public async Task<string[]> ReadAllLines(string path) => await _files
.Filter(f => f.Path == path)
.HeadOrNone()
.Select(f => f.Contents)
.IfNoneAsync(string.Empty)
.Map(s => s.Split(Environment.NewLine));
public Task<byte[]> GetHash(string path) => throw new NotSupportedException();
public string GetCustomOrDefaultFile(string folder, string file)
{
string path = Path.Combine(folder, file);
return FileExists(path) ? path : Path.Combine(folder, $"_{file}");
}
private static List<DirectoryInfo> Split(DirectoryInfo path)
{
var result = new List<DirectoryInfo>();
if (path == null || string.IsNullOrWhiteSpace(path.FullName))
{
return result;
}
if (path.Parent != null)
{
result.AddRange(Split(path.Parent));
}
result.Add(path);
return result;
}
}

48
ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs

@ -8,13 +8,8 @@ namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture] [TestFixture]
public class ChronologicalContentTests public class ChronologicalContentTests
{ {
[SetUp]
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
private CancellationToken _cancellationToken;
[Test] [Test]
public void Episodes_Should_Sort_By_Aired() public void Episodes_Should_Sort_By_ReleaseDate()
{ {
List<MediaItem> contents = Episodes(10); List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState(); var state = new CollectionEnumeratorState();
@ -29,6 +24,22 @@ public class ChronologicalContentTests
} }
} }
[Test]
public void OtherVideos_Should_Sort_By_ReleaseDate()
{
List<MediaItem> contents = OtherVideos(10);
var state = new CollectionEnumeratorState();
var chronologicalContent = new ChronologicalMediaCollectionEnumerator(contents, state);
for (var i = 1; i <= 10; i++)
{
chronologicalContent.Current.IsSome.ShouldBeTrue();
chronologicalContent.Current.Map(x => x.Id).IfNone(-1).ShouldBe(10 - i);
chronologicalContent.MoveNext(Option<DateTimeOffset>.None);
}
}
[Test] [Test]
public void State_Index_Should_Increment() public void State_Index_Should_Increment()
{ {
@ -77,14 +88,31 @@ public class ChronologicalContentTests
Range(1, count).Map(i => (MediaItem)new Episode Range(1, count).Map(i => (MediaItem)new Episode
{ {
Id = i, Id = i,
EpisodeMetadata = new List<EpisodeMetadata> EpisodeMetadata =
{ [
new() new EpisodeMetadata
{ {
ReleaseDate = new DateTime(2020, 1, i), ReleaseDate = new DateTime(2020, 1, i),
EpisodeNumber = 20 - i EpisodeNumber = 20 - i
} }
} ]
})
.Reverse()
.ToList();
private static List<MediaItem> OtherVideos(int count) =>
Range(1, count).Map(i => (MediaItem)new OtherVideo
{
// ids need to count down because fallback sorting is by id
// and we need the test to fail when these are sorted by id
Id = count - i,
OtherVideoMetadata =
[
new OtherVideoMetadata
{
ReleaseDate = new DateTime(2020, 1, i)
}
]
}) })
.Reverse() .Reverse()
.ToList(); .ToList();

11
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs

@ -1,6 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@ -8,6 +7,7 @@ using ErsatzTV.Core.Tests.Fakes;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
@ -588,7 +588,6 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -596,7 +595,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -714,7 +713,6 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -722,7 +720,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -842,7 +840,6 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -850,7 +847,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);

35
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs

@ -1,7 +1,6 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@ -9,6 +8,7 @@ using ErsatzTV.Core.Tests.Fakes;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
@ -597,7 +597,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -605,7 +604,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -717,7 +716,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -725,7 +723,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -888,7 +886,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -896,7 +893,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1018,7 +1015,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1026,7 +1022,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1147,7 +1143,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1155,7 +1150,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1277,7 +1272,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1285,7 +1279,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1412,7 +1406,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1420,7 +1413,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1547,7 +1540,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1555,7 +1547,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1691,7 +1683,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1699,7 +1690,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1828,7 +1819,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1836,7 +1826,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1931,7 +1921,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1939,7 +1928,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);

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

Loading…
Cancel
Save