Compare commits

...

55 Commits

Author SHA1 Message Date
Jason Dove 474e647d6d
more jellyfin performance improvements (#2747) 14 hours ago
Jon Crall daff1c6533
Add select all controls to media lists (#2738) 21 hours ago
Jason Dove 14d2dd0c3a
optimize jellyfin database fields and indexes (#2746) 22 hours ago
Jason Dove c606319030
add some performance troubleshooting env vars (#2745) 22 hours 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) 4 weeks ago
Jason Dove 54606c76f9
framerate improvements (#2692) 4 weeks 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 @@ @@ -3,11 +3,11 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2025.3.0",
"version": "2025.3.0.2",
"commands": [
"jb"
],
"rollForward": false
}
}
}
}

18
.github/workflows/artifacts.yml

@ -48,7 +48,7 @@ jobs: @@ -48,7 +48,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@ -72,8 +72,8 @@ jobs: @@ -72,8 +72,8 @@ jobs:
shell: bash
run: |
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Bundle
shell: bash
@ -163,7 +163,7 @@ jobs: @@ -163,7 +163,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@ -180,8 +180,8 @@ jobs: @@ -180,8 +180,8 @@ jobs:
# Build everything
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
mkdir "$release_name"
mv scanner/* "$release_name/"
mv main/* "$release_name/"
@ -220,7 +220,7 @@ jobs: @@ -220,7 +220,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@ -232,8 +232,8 @@ jobs: @@ -232,8 +232,8 @@ jobs:
shell: bash
run: |
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Upload .NET Artifact
uses: actions/upload-artifact@v4

31
.github/workflows/pr.yml

@ -2,6 +2,31 @@ @@ -2,6 +2,31 @@
on:
pull_request:
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:
runs-on: windows-latest
steps:
@ -11,7 +36,7 @@ jobs: @@ -11,7 +36,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@ -52,7 +77,7 @@ jobs: @@ -52,7 +77,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@ -80,7 +105,7 @@ jobs: @@ -80,7 +105,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.203
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear

93
CHANGELOG.md

@ -5,6 +5,88 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -5,6 +5,88 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### 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
- Graphics Engine:
- 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/). @@ -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 disabled text color and `(D)` and `(H)` labels for disabled and hidden channels in channel list
- Graphics engine: fix subtitle path escaping and font loading
- Fix corrupt output (green artifacts) when decoding certain 10-bit content using AMD Polaris GPUs
- Work around sequential schedule validation limit (1000/hr by Newtonsoft.Json.Schema library)
- Playout builds now use JsonSchema.Net library which has no validation limit
- Validation tool in the UI still uses Newtonsoft.Json.Schema (with 1000/hr limit) as the error output is easier to understand
- Fix editing scripted and sequential playouts when using MySql
- Fix HLS Direct streams remaining open after client disconnect
- Always log scanner exit code when it is non-zero
### Changed
- Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them
@ -97,6 +186,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -97,6 +186,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Template groups in template list
- Block groups and blocks in template editor
- Replace template tree view with searchable table (like blocks)
- Upgrade to dotnet 10
## [25.8.0] - 2025-10-26
### Added
@ -2981,7 +3071,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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.
[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.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

3
Directory.Build.props

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

15
Directory.Build.targets

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

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

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

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

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

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

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Infrastructure.Data;
@ -38,6 +39,14 @@ public class UpdateChannelNumbersHandler( @@ -38,6 +39,14 @@ public class UpdateChannelNumbersHandler(
foreach (var channel in channelsToUpdate)
{
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

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

@ -1,3 +1,5 @@ @@ -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 @@ @@ -1,29 +1,22 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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;
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)
public async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
{
try
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
FFmpegProfile ffmpegProfile = await dbContext.Channels
.AsNoTracking()
@ -34,11 +27,11 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O @@ -34,11 +27,11 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
if (!ffmpegProfile.NormalizeFramerate)
{
return Option<int>.None;
return Option<FrameRate>.None;
}
// 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
.AsNoTracking()
@ -68,51 +61,53 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O @@ -68,51 +61,53 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
.Flatten()
.Map(mv => mv.RFrameRate)
.Map(mv => new FrameRate(mv.RFrameRate))
.ToList();
var distinct = frameRates.Distinct().ToList();
if (distinct.Count > 1)
{
// TODO: something more intelligent than minimum framerate?
int result = frameRates.Map(ParseFrameRate).Min();
if (result < 24)
var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList();
if (validFrameRates.Count > 0)
{
_logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate);
logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
request.ChannelNumber,
distinct,
24,
result);
return 24;
distinct.Map(fr => fr.RFrameRate),
result.RFrameRate);
return result;
}
_logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
FrameRate minFrameRate = frameRates.MinBy(fr => fr.ParsedFrameRate);
logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
request.ChannelNumber,
distinct,
result);
return result;
distinct.Map(fr => fr.RFrameRate),
FrameRate.DefaultFrameRate.RFrameRate,
minFrameRate.RFrameRate);
return FrameRate.DefaultFrameRate;
}
if (distinct.Count != 0)
{
_logger.LogInformation(
logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
request.ChannelNumber,
distinct[0]);
distinct[0].RFrameRate);
}
else
{
_logger.LogInformation(
logger.LogInformation(
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
request.ChannelNumber);
}
}
catch (Exception ex)
{
_logger.LogWarning(
logger.LogWarning(
ex,
"Unexpected error checking frame rates on channel {ChannelNumber}",
request.ChannelNumber);
@ -120,22 +115,4 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O @@ -120,22 +115,4 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
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 @@ @@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Text;
using System.Text.RegularExpressions;
using ErsatzTV.Core;
@ -15,14 +16,17 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E @@ -15,14 +16,17 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
private readonly IFileSystem _fileSystem;
public GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem)
{
_dbContextFactory = dbContextFactory;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
}
@ -39,7 +43,7 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E @@ -39,7 +43,7 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
.ToImmutableHashSet();
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_localFileSystem.FileExists(channelsFile))
if (!_fileSystem.File.Exists(channelsFile))
{
return BaseError.New($"Required file {channelsFile} is missing");
}

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

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

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

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

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

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

16
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
@ -11,18 +11,14 @@ @@ -11,18 +11,14 @@
<ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.9.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.19.1" />
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
</ItemGroup>

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

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

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

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

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

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

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

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

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

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

1
ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs

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

1
ErsatzTV.Application/FFmpegProfiles/Mapper.cs

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

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

@ -5,18 +5,14 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper; @@ -5,18 +5,14 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
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(
GetFFmpegProfileById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken)

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

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
@ -10,6 +11,7 @@ namespace ErsatzTV.Application.Graphics; @@ -10,6 +11,7 @@ namespace ErsatzTV.Application.Graphics;
public class RefreshGraphicsElementsHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IGraphicsElementLoader graphicsElementLoader,
ILogger<RefreshGraphicsElementsHandler> logger)
@ -24,7 +26,7 @@ public class RefreshGraphicsElementsHandler( @@ -24,7 +26,7 @@ public class RefreshGraphicsElementsHandler(
.ToListAsync(cancellationToken);
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();
foreach (GraphicsElement existing in missing)
@ -121,6 +123,26 @@ public class RefreshGraphicsElementsHandler( @@ -121,6 +123,26 @@ public class RefreshGraphicsElementsHandler(
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);
}

1
ErsatzTV.Application/Graphics/Mapper.cs

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

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

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

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

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

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

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

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

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

2
ErsatzTV.Application/MediaCards/Mapper.cs

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

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

@ -67,7 +67,7 @@ public class CreateSmartCollectionHandler : @@ -67,7 +67,7 @@ public class CreateSmartCollectionHandler :
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.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");
return (result1, result2).Apply((_, _) => createSmartCollection.Name);

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

@ -12,7 +12,9 @@ using Microsoft.EntityFrameworkCore; @@ -12,7 +12,9 @@ using Microsoft.EntityFrameworkCore;
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 IDbContextFactory<TvContext> _dbContextFactory;
@ -71,7 +73,8 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio @@ -71,7 +73,8 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
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(
TvContext dbContext,
@ -80,4 +83,23 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio @@ -80,4 +83,23 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken)
.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 @@ -57,14 +57,6 @@ public class GetPlaylistItemsHandler(IDbContextFactory<TvContext> dbContextFacto
.ThenInclude(mm => mm.Artwork)
.ToListAsync(cancellationToken);
if (allItems.All(bi => !bi.IncludeInProgramGuide))
{
foreach (PlaylistItem bi in allItems)
{
bi.IncludeInProgramGuide = true;
}
}
return allItems.Map(Mapper.ProjectToViewModel).ToList();
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

36
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

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

59
ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs

@ -3,11 +3,54 @@ using ErsatzTV.Core.Interfaces.Streaming; @@ -3,11 +3,54 @@ using ErsatzTV.Core.Interfaces.Streaming;
namespace ErsatzTV.Application.Streaming;
public record PlayoutItemProcessModel(
Command Process,
Option<GraphicsEngineContext> GraphicsEngineContext,
Option<TimeSpan> MaybeDuration,
DateTimeOffset Until,
bool IsComplete,
Option<long> SegmentKey,
Option<int> MediaItemId);
public class PlayoutItemProcessModel
{
public PlayoutItemProcessModel(
Command process,
Option<GraphicsEngineContext> graphicsEngineContext,
Option<TimeSpan> maybeDuration,
DateTimeOffset until,
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 @@ -45,6 +45,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
DateTimeOffset.MaxValue,
true,
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( @@ -21,12 +21,10 @@ public class GetErrorProcessHandler(
string ffprobePath,
CancellationToken cancellationToken)
{
DateTimeOffset now = DateTimeOffset.Now;
Command process = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
now,
request.Now,
request.MaybeDuration,
request.ErrorMessage,
request.HlsRealtime,
@ -42,7 +40,9 @@ public class GetErrorProcessHandler( @@ -42,7 +40,9 @@ public class GetErrorProcessHandler(
request.MaybeDuration,
request.Until,
true,
now.ToUnixTimeSeconds(),
Option<int>.None);
request.Now.ToUnixTimeSeconds(),
Option<int>.None,
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.Security.Cryptography;
using System.Text;
using CliWrap;
@ -8,7 +9,6 @@ using Dapper; @@ -8,7 +9,6 @@ using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -17,7 +17,7 @@ using Microsoft.Extensions.Logging; @@ -17,7 +17,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Subtitles;
[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(
TvContext dbContext,
@ -133,7 +133,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local @@ -133,7 +133,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
{
string fullOutputPath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.OutputPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath));
if (localFileSystem.FileExists(fullOutputPath))
if (fileSystem.File.Exists(fullOutputPath))
{
File.Delete(fullOutputPath);
}
@ -193,7 +193,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local @@ -193,7 +193,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
}
string fullOutputPath = Path.Combine(FileSystemLayout.FontsCacheFolder, fontStream.FileName);
if (localFileSystem.FileExists(fullOutputPath))
if (fileSystem.File.Exists(fullOutputPath))
{
// already extracted
continue;
@ -212,7 +212,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local @@ -212,7 +212,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
// ffmpeg seems to return exit code 1 in all cases when dumping an attachment
// 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);
}
@ -300,7 +300,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local @@ -300,7 +300,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
{
foreach (string path in GetRelativeOutputPath(mediaItemId, subtitle))
{
return !localFileSystem.FileExists(path);
return !fileSystem.File.Exists(path);
}
return false;

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

@ -0,0 +1,3 @@ @@ -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 @@ @@ -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 @@ @@ -1,9 +1,8 @@
using Dapper;
using System.IO.Abstractions;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby;
@ -30,6 +29,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -30,6 +29,7 @@ public class PrepareTroubleshootingPlaybackHandler(
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
IFFmpegProcessService ffmpegProcessService,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
ISongVideoGenerator songVideoGenerator,
IWatermarkSelector watermarkSelector,
@ -37,7 +37,11 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -37,7 +37,11 @@ public class PrepareTroubleshootingPlaybackHandler(
IMediator mediator,
LoggingLevelSwitches loggingLevelSwitches,
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(
PrepareTroubleshootingPlayback request,
@ -85,7 +89,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -85,7 +89,7 @@ public class PrepareTroubleshootingPlaybackHandler(
HlsRealtime: false,
start,
TimeSpan.Zero,
TargetFramerate: Option<int>.None,
TargetFramerate: Option<FrameRate>.None,
IsTroubleshooting: true,
request.FFmpegProfileId),
cancellationToken);
@ -103,7 +107,10 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -103,7 +107,10 @@ public class PrepareTroubleshootingPlaybackHandler(
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)
@ -316,7 +323,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -316,7 +323,7 @@ public class PrepareTroubleshootingPlaybackHandler(
inPoint,
channelStartTime: DateTimeOffset.Now,
TimeSpan.Zero,
Option<int>.None,
Option<FrameRate>.None,
FileSystemLayout.TranscodeTroubleshootingFolder,
_ => { },
canProxy: true,
@ -373,80 +380,13 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -373,80 +380,13 @@ public class PrepareTroubleshootingPlaybackHandler(
TvContext dbContext,
PrepareTroubleshootingPlayback request,
CancellationToken cancellationToken) =>
(await MediaItemMustExist(dbContext, request, cancellationToken),
(await MediaItemMustExist(dbContext, request.MediaItemId, cancellationToken),
await FFmpegPathMustExist(dbContext, cancellationToken),
await FFprobePathMustExist(dbContext, cancellationToken),
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
.Apply((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(
TvContext dbContext,
CancellationToken cancellationToken) =>
@ -462,115 +402,4 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -462,115 +402,4 @@ public class PrepareTroubleshootingPlaybackHandler(
.Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
.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( @@ -120,7 +120,8 @@ public partial class StartTroubleshootingPlaybackHandler(
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;
foreach (GraphicsEngineContext graphicsEngineContext in request.PlayoutItemResult.GraphicsEngineContext)

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

@ -0,0 +1,167 @@ @@ -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; @@ -5,13 +5,13 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentAll
{
[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")]
public string FillerKind { get; set; }
public string? FillerKind { get; set; }
[Description("Overrides the title used in the EPG")]
public string CustomTitle { get; set; }
public string? CustomTitle { 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 @@ @@ -1,4 +1,5 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.Core.Api.ScriptedPlayout;
@ -7,10 +8,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout; @@ -7,10 +8,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentCollection
{
[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")]
public string Collection { get; init; }
public required string Collection { get; init; }
[Description("The playback order; only chronological and shuffle are currently supported")]
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; @@ -5,8 +5,8 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentCreatePlaylist
{
[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")]
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; @@ -5,11 +5,11 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentMarathon
{
[Description("Unique name used to reference this content throughout the scripted schedule")]
public string Key { get; set; }
public required string Key { get; set; }
[Description(
"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")]
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; @@ -7,10 +7,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentMultiCollection
{
[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")]
public string MultiCollection { get; set; }
public required string MultiCollection { get; set; }
[Description("The playback order; only chronological and shuffle are currently supported")]
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; @@ -5,11 +5,11 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentPlaylist
{
[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")]
public string Playlist { get; set; }
public required string Playlist { get; set; }
[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 @@ @@ -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; @@ -5,10 +5,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentShow
{
[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")]
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")]
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; @@ -7,10 +7,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContentSmartCollection
{
[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")]
public string SmartCollection { get; set; }
public required string SmartCollection { get; set; }
[Description("The playback order; only chronological and shuffle are currently supported")]
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; @@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlGraphicsOn
{
[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; } = [];
}

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

@ -5,5 +5,5 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout; @@ -5,5 +5,5 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlPreRollOn
{
[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; @@ -5,8 +5,8 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlSkipItems
{
[Description("The 'key' for the content")]
public string Content { get; set; }
public required string Content { get; set; }
[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; @@ -5,11 +5,11 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlSkipToItem
{
[Description("The 'key' for the content")]
public string Content { get; set; }
public required string Content { get; set; }
[Description("The season number")]
public int Season { get; set; }
public required int Season { get; set; }
[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 @@ -8,5 +8,5 @@ public record ControlStartEpgGroup
public bool Advance { get; set; } = true;
[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; @@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlWaitUntil
{
[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.")]
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; @@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlWaitUntilExact
{
[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.")]
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; @@ -5,5 +5,5 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ControlWatermarkOn
{
[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; @@ -4,8 +4,8 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public class PeekItemDuration
{
public string Content { get; set; }
public required string Content { get; set; }
[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; @@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlaylistItem
{
[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; @@ -5,14 +5,14 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutContext
{
[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")]
public DateTimeOffset StartTime { get; set; }
public required DateTimeOffset StartTime { get; set; }
[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")]
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; @@ -5,15 +5,15 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutCount
{
[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")]
public string FillerKind { get; set; }
public string? FillerKind { get; set; }
[Description("Overrides the title used in the EPG")]
public string CustomTitle { get; set; }
public string? CustomTitle { 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; @@ -5,14 +5,14 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutDuration
{
[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")]
public string Duration { get; set; }
public required string Duration { get; set; }
[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.")]
public string Fallback { get; set; }
public string? Fallback { get; set; }
[Description("Controls whether content will be trimmed to exactly fit the specified duration")]
public bool Trim { get; set; }
@ -29,10 +29,10 @@ public record PlayoutDuration @@ -29,10 +29,10 @@ public record PlayoutDuration
public bool OfflineTail { get; set; }
[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")]
public string CustomTitle { get; set; }
public string? CustomTitle { 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; @@ -5,14 +5,14 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutPadToNext
{
[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")]
public int Minutes { get; set; }
public required int Minutes { get; set; }
[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.")]
public string Fallback { get; set; }
public string? Fallback { get; set; }
[Description("Controls whether content will be trimmed to exactly fit the specified interval")]
public bool Trim { get; set; }
@ -29,10 +29,10 @@ public record PlayoutPadToNext @@ -29,10 +29,10 @@ public record PlayoutPadToNext
public bool OfflineTail { get; set; } = true;
[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")]
public string CustomTitle { get; set; }
public string? CustomTitle { 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; @@ -5,10 +5,10 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutPadUntil
{
[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")]
public string When { get; set; }
public required string When { get; set; }
[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.")]
@ -16,7 +16,7 @@ public record PlayoutPadUntil @@ -16,7 +16,7 @@ public record PlayoutPadUntil
[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.")]
public string Fallback { get; set; }
public string? Fallback { get; set; }
[Description("Controls whether content will be trimmed to exactly fit until the specified time")]
public bool Trim { get; set; }
@ -34,10 +34,10 @@ public record PlayoutPadUntil @@ -34,10 +34,10 @@ public record PlayoutPadUntil
public bool OfflineTail { get; set; }
[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")]
public string CustomTitle { get; set; }
public string? CustomTitle { 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; @@ -5,14 +5,14 @@ namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record PlayoutPadUntilExact
{
[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")]
public DateTimeOffset When { get; set; }
public required DateTimeOffset When { get; set; }
[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.")]
public string Fallback { get; set; }
public string? Fallback { get; set; }
[Description("Controls whether content will be trimmed to exactly fit until the specified time")]
public bool Trim { get; set; }
@ -30,10 +30,10 @@ public record PlayoutPadUntilExact @@ -30,10 +30,10 @@ public record PlayoutPadUntilExact
public bool OfflineTail { get; set; }
[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")]
public string CustomTitle { get; set; }
public string? CustomTitle { get; set; }
public bool DisableWatermarks { get; set; }
}

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

@ -0,0 +1,14 @@ @@ -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 @@ @@ -1,32 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<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="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.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.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="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.1" />
</ItemGroup>
<ItemGroup>

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

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

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

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

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

@ -1,8 +0,0 @@ @@ -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 @@ @@ -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 @@ @@ -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; @@ -8,13 +8,8 @@ namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
public class ChronologicalContentTests
{
[SetUp]
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
private CancellationToken _cancellationToken;
[Test]
public void Episodes_Should_Sort_By_Aired()
public void Episodes_Should_Sort_By_ReleaseDate()
{
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState();
@ -29,6 +24,22 @@ public class ChronologicalContentTests @@ -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]
public void State_Index_Should_Increment()
{
@ -77,14 +88,31 @@ public class ChronologicalContentTests @@ -77,14 +88,31 @@ public class ChronologicalContentTests
Range(1, count).Map(i => (MediaItem)new Episode
{
Id = i,
EpisodeMetadata = new List<EpisodeMetadata>
{
new()
EpisodeMetadata =
[
new EpisodeMetadata
{
ReleaseDate = new DateTime(2020, 1, 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()
.ToList();

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

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

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

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

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

Loading…
Cancel
Save