Compare commits

..
67 Commits
Author SHA1 Message Date
Jason Dove 9eb7bbf0e6 prep for release v25.8.0 [no ci] 2025-10-26 09:22:02 -05:00
Jason DoveandGitHub e851a295a6 fix scripted schedule building across offsets (#2569) 2025-10-26 09:07:21 -05:00
Jason DoveandGitHub 3b254735e6 fix transcoding tests; fix vaapi subtitle crop (#2568)
* fix transcoding tests using text subtitles

* fix vaapi picture subtitle overlay with crop

* more test improvements
2025-10-26 08:51:24 -05:00
Jason DoveandGitHub 1f8834c280 block playout fixes; hls direct fixes (#2566)
* fix block playout builder with empty collection

* fix hls direct when selecting audio

* allow embedded subtitles with hls direct
2025-10-25 06:35:56 -05:00
Jason DoveandGitHub 545db4db9b update dependencies (#2565)
* logging cleanup

* update dependencies
2025-10-24 09:17:45 -05:00
Jason DoveandGitHub e590298b93 add deep scans for external collections (#2562) 2025-10-23 19:32:38 -05:00
Jason DoveandGitHub a47510fef3 add aac (latm) audio format (#2561)
* add aac (latm) audio format

* update changelog
2025-10-23 15:56:13 -05:00
Jason DoveandGitHub e089b12c2b trakt list fixes (#2560)
* show reset playout build failures

* fix scheduling trakt list playlists that contain shows
2025-10-23 11:15:55 -05:00
Jason DoveandGitHub 82e0fcaec8 maintain source fps when using qsv (#2558) 2025-10-22 14:27:01 -05:00
Jason DoveandGitHub f7c699248c fix collection and network scanners (#2557) 2025-10-22 08:59:06 -05:00
Jason DoveandGitHub 2d53063ce9 edit jellyfin and emby connection info (#2556) 2025-10-21 19:10:48 -05:00
Jason DoveandGitHub 626048f9c3 change how scanner and main process communicate (#2555)
* report scanner progress using api

* process scanner search index updates through api

* update changelog

* update dependencies
2025-10-21 15:08:49 -05:00
Jason DoveandGitHub 2ef2b0299a switch back from fmp4 to ts segments (#2554)
* restore pts offset calculation

* use ts segments again

* update changelog
2025-10-21 12:17:05 -05:00
Jason DoveandGitHub fcce53a3df fix a couple ui errors (#2551) 2025-10-20 10:54:43 -05:00
Jason DoveandGitHub d4353f6d42 add episode thumbnail to xmltv template (#2550) 2025-10-19 12:13:40 -05:00
Jason DoveandGitHub 64ea413b6f fix nvidia 10 bit text subtitles and permanent watermark (#2549)
* fix nvidia 10 bit text subtitles and watermark

* update changelog
2025-10-19 11:18:15 -05:00
Jason DoveandGitHub d14ebf3522 properly track discontinuity sequences with fmp4 (#2548)
* properly track discontinuity sequences with fmp4

* update dependencies
2025-10-19 10:31:11 -05:00
Jason DoveandGitHub 889904e70d fix management of fmp4 init segments (#2546) 2025-10-18 22:52:45 -05:00
Jason DoveandGitHub 35e7922836 fix mpegts wrapper with fmp4 segmenter source (#2545) 2025-10-18 16:37:31 -05:00
Jason DoveandGitHub ffe15629cb consolidate streaming modes (#2544)
* consolidate segmenters

* let old segmenter mode query params continue to work
2025-10-18 10:54:30 -05:00
Jason DoveandGitHub ba5a027525 reduce playout log spam (#2543) 2025-10-18 08:59:17 -05:00
Jason DoveandGitHub a33ac4a048 fix qsv audio sync (#2542)
* fix qsv audio sync

* cast a wider net

* always reset qsv pts
2025-10-17 20:01:25 -05:00
Jason DoveandGitHub 7ae028e2e9 fix trakt list sync (#2540) 2025-10-16 14:00:56 -05:00
Jason DoveandGitHub 6404dee646 update dependencies (#2539) 2025-10-15 08:41:38 -05:00
Jason DoveandGitHub 940d26419c fix buffer logging (#2535) 2025-10-14 15:55:27 -05:00
Jason DoveandGitHub 9bae8e73bf slightly increase throttled readrate in segmenter (#2534) 2025-10-14 14:53:56 -05:00
Jason DoveandGitHub f41f4b19d4 wait for two initial segments in playback troubleshooting (#2532) 2025-10-14 11:45:11 -05:00
Jason DoveandGitHub 917acf9683 fix hls segmenter fmp4 on windows (#2531)
* fix hls segmenter fmp4 on windows

* try to fix by using working directory
2025-10-14 10:29:08 -05:00
Jason DoveandGitHub da4687ac0f fix nvidia capabilities on windows (#2530) 2025-10-14 09:32:10 -05:00
Jason DoveandGitHub d1af6599f0 fix segmenter repeating content when stream first starts (#2529) 2025-10-14 08:53:22 -05:00
Jason DoveandGitHub 4e43817f8e add graphics engine text halo (#2528) 2025-10-14 06:41:32 -05:00
Jason DoveandGitHub ebcd9a35a7 fix scaling image subs with vaapi (#2526) 2025-10-13 19:42:01 -05:00
Jason DoveandGitHub ea5956a268 improve stream startup (#2525) 2025-10-13 16:26:06 -05:00
Jason DoveandGitHub 65ff1f5502 improve live stream reliability (#2524) 2025-10-13 15:41:48 -05:00
Jason DoveandGitHub 5ef8b04119 fix intermittent watermark opacity (#2523) 2025-10-13 12:00:58 -05:00
Jason DoveandGitHub 99837e808a fix speed parsing (#2522) 2025-10-13 11:49:39 -05:00
Jason DoveandGitHub 7c2083d3f2 add playback troubleshooting speed indicator (#2521)
* more api fixes

* add playback troubleshooting speed indicator
2025-10-13 11:25:56 -05:00
Jason DoveandGitHub b851a7daba api changes for ffmpeg profiles (#2520) 2025-10-13 06:44:32 -05:00
Jason DoveandGitHub 48e7c85f7b api changes to support etvcli (#2519) 2025-10-12 10:13:30 -05:00
Jason DoveandGitHub bf4182f115 add copy block button (#2518) 2025-10-11 13:35:22 -05:00
Jason DoveandGitHub fb9ca8953e add text element formatting options (#2517) 2025-10-11 12:45:20 -05:00
Jason DoveandGitHub 48310e044b optimize first run (#2516)
* do not accept ui requests until database is ready

* add empty database to greatly speed up initial startup

* remove middleware
2025-10-11 09:05:31 -05:00
Jason DoveandGitHub 144b3fe80b fix remote stream durations in playouts (#2515) 2025-10-11 08:30:24 -05:00
Jason DoveandGitHub 4a754c4e6a add helper text for sequential schedule file (#2514) 2025-10-11 06:43:59 -05:00
Jason DoveandGitHub 7059669023 add schedule file names to playouts table (#2513) 2025-10-10 21:55:22 -05:00
Jason DoveandGitHub c03f81a465 add block playout troubleshooting tool (#2512)
* rename yaml validation to sequential schedule validation

* some better exception handling

* add block playout troubleshooting page

* add paged block playout history

* add history details

* update changelog
2025-10-10 20:50:01 -05:00
Jason DoveandGitHub e3d07050bf graphics element template data improvements (#2511)
* add other video template data

* add path and related functions
2025-10-10 13:47:18 -05:00
Jason DoveandGitHub 5a88bfc310 use old ffmpeg pipeline for single permanent watermark (#2510) 2025-10-10 10:14:29 -05:00
Jason DoveandGitHub dd92a65742 more nvidia capabilities safety (#2509) 2025-10-09 22:16:30 -05:00
Jason DoveandGitHub 07ffa1642b fix nvidia troubleshooting on arm64 (#2508) 2025-10-09 22:07:07 -05:00
Jason DoveandGitHub 6fc602323f catch proper exception (#2507) 2025-10-09 21:46:54 -05:00
Jason DoveandGitHub d5fd8e7be6 another attempt at fixing nvidia startup (#2506)
* another attempt at fixing nvidia startup

* update dependencies
2025-10-09 21:36:55 -05:00
Jason DoveandGitHub dba5485300 fix nvidia startup errors on arm64 (#2505) 2025-10-09 21:26:27 -05:00
Jason Dove 6847a133ca prep for release v25.7.1 [no ci] 2025-10-09 10:40:05 -05:00
Jason DoveandGitHub fd60c120ae add more cuda logging (#2503) 2025-10-08 20:04:18 -05:00
Jason DoveandGitHub 371d1d89fb push cuda context when checking capabilities (#2502) 2025-10-08 19:44:29 -05:00
Jason DoveandGitHub ec6bc797f4 update cuda failure logging (#2501) 2025-10-08 19:13:24 -05:00
Jason DoveandGitHub 4c57167864 package windows artifacts using zip instead of 7z (#2496) 2025-10-07 10:11:51 -05:00
Jason DoveandGitHub 9016523757 cleanup some exceptions; add health check (#2495)
* handle artwork timeouts so they aren't reported

* catch some more cancellation errors

* add free space validation on startup

* add downgrade health check

* update dependencies
2025-10-07 09:50:55 -05:00
Jason Dove 6a38c91d54 update changelog [no ci] 2025-10-06 16:25:28 -05:00
Jason DoveandGitHub 9ec4d0a85c show graphics engine scriban errors in log (#2491)
* show graphics engine scriban errors in log

* better fix
2025-10-06 16:24:40 -05:00
Jason DoveandGitHub 4cb98242ba do not allow deleting default ffmpeg profile (#2490)
* remove dead code

* do not allow deleting default ffmpeg profile
2025-10-06 06:11:36 -05:00
Jason DoveandGitHub 0e2084838a use a table for blocks (#2489) 2025-10-05 09:45:29 -05:00
Jason DoveandGitHub f3f900b4ca fix playback troubleshooting (#2488) 2025-10-05 06:06:56 -05:00
Jason DoveandGitHub c39858b2d8 fix hls direct (#2487) 2025-10-04 15:36:36 -05:00
Jason DoveandGitHub 0363609923 allow h264 video profile using vaapi (#2485) 2025-10-04 08:40:10 -05:00
Jason DoveandGitHub a02aa37957 do not delete ffmpeg profile used by channel (#2484) 2025-10-04 08:29:07 -05:00
317 changed files with 20662 additions and 3608 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"jetbrains.resharper.globaltools": { "jetbrains.resharper.globaltools": {
"version": "2025.2.2.1", "version": "2025.2.3",
"commands": [ "commands": [
"jb" "jb"
], ],
+3 -3
View File
@@ -292,7 +292,7 @@ jobs:
release_name="ErsatzTV-${{ inputs.release_version }}-win-x64" release_name="ErsatzTV-${{ inputs.release_version }}-win-x64"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
mkdir "$release_name" mkdir "$release_name"
mv dotnet-build/scanner/* "$release_name/" mv dotnet-build/scanner/* "$release_name/"
mv dotnet-build/main/* "$release_name/" mv dotnet-build/main/* "$release_name/"
@@ -302,8 +302,8 @@ jobs:
mv rust-build/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe" mv rust-build/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r 7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
rm -f "$release_name/ffplay.exe" rm -f "$release_name/ffplay.exe"
7z a -tzip "${release_name}.zip" "./${release_name}/*" (cd "${release_name}" && zip -r "../${release_name}.zip" .)
- name: Delete old release assets - name: Delete old release assets
uses: mknejp/delete-release-assets@v1 uses: mknejp/delete-release-assets@v1
+86 -2
View File
@@ -5,7 +5,89 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
## [25.7.0] - 2025-09-14 ## [25.8.0] - 2025-10-26
### Added
- Graphics engine:
- Add template data (like `MediaItem_Title`) for other video files
- Add `MediaItem_Path` for movies, episodes, music videos and other videos
- Add `get_directory_name` and `get_filename_without_extension` functions for path processing
- Add `text_align` property to text graphics elements (values: `left`, `right` and `center`)
- Add `MiddleCenter` value to `location` property on all graphics elements
- Positive and negative margins can be used to offset from center as desired
- Add `line_height` property to text element style definition
- This is a multiplier that defaults to 1.0 when unspecified
- Add `halo_color`, `halo_width` and `halo_blur` properties to text element style definition
- These can be used to "outline" text with the configured color (e.g. `#000000`), width (e.g. `10`) and amount of blur (e.g. `2`)
- Add `Block Playout Troubleshooting` tool to help investigate block playout history
- Add sequential schedule file and scripted schedule file names to playouts table
- Add empty (but already up-to-date) sqlite3 database to greatly speed up initial startup for fresh installs
- Add button to copy/clone block from blocks table
- Add playback speed to playback troubleshooting output
- Speed is relative to realtime (1.0x is realtime)
- Speeds < 0.9x will be colored red, between 0.9x and 1.1x colored yellow, and > 1.1x colored green
- Add episode thumbnail artwork URL to XMLTV template
- By default, poster will be added as image with type "poster" and thumbnail will be added as image with type "still"
- Poster will continue to be added as icon by default
- Add buttons to edit Jellyfin and Emby connection information in **Media Sources** > **Jellyfin** and **Media Sources** > **Emby**
- Add audio format `aac (latm)` for DVB-C compatibility; `aac` uses ADTS by default which is required in most cases
- Add deep scan option for external collections (Plex, Jellyfin, Emby)
- Jellyfin and Emby collection scans have always been deep scans
- Now, by default, they will be quick scans that trust Jellyfin and Emby's etags for detecting changes
- If a quick scan misses updating a collection, deep scans can be triggered manually
### Fixed
- Fix NVIDIA startup errors on arm64
- Fix remote stream durations in playouts created using block, sequential or scripted schedules
- Fix playback troubleshooting selecting a subtitle even with no subtitle stream selected in the UI
- Fix intermittent watermark opacity
- Improve reliability of live remote streams; they should transcode closer to realtime in most cases
- Dramatically improve stream startup time
- VAAPI: fix scaling image-based subtitles (e.g. dvdsub)
- VAAPI: fix overlaying picture subtitles with scaling behavior crop
- Fix HLS Segmenter (fmp4) on Windows
- Playback troubleshooting: wait for at least 2 initial segments (up to configured initial segment count) to reduce stalls
- Fix Trakt List sync
- Fix QSV audio sync
- Fix QSV capability detection on Linux using non-drm displays (e.g. wayland)
- Fix playlist filtering bug that made HLS Segmenter more likely to fail when streaming for multiple hours
- Fix NVIDIA overlaying text subtitles and permanent watermark on 10-bit content
- Fix UI error adding deco
- Fix UI error editing watermarks and graphics elements on blocks
- Fix showing playout build failure details when resetting a playout
- Fix scheduling auto-generated trakt list playlists that contain shows
- Fix playout builder getting stuck (forever) on block item with an empty collection
- Fix HLS Direct playback when using custom stream selector or preferred audio language/title
- Fix selecting embedded subtitles (text and picture) with HLS Direct
- Fix building scripted schedules across a UTC offset change
### Changed
- Do not use graphics engine for single, permanent watermark
- Rename `YAML Validation` tool to `Sequential Schedule Validation`
- Greatly reduce debug log spam during playout builds by logging summaries of certain warnings at the end
- Remove *experimental* `HLS Segmenter V2` streaming mode; it is not possible to maintain quality output using this mode
- Remove *experimental* `HLS Segmenter (fmp4)` streaming mode; this mode only worked properly in a browser, many clients did not like it
- Change how scanner process and main process communicate, which should improve reliability of search index updates when scanning
## [25.7.1] - 2025-10-09
### Added
- Add search field to filter blocks table
- Show full error/exception details in playback troubleshooting logs
- Add basic free space validation on startup
- ETV will now fail to start with less than 128 MB free space in config or transcode folders
- Add downgrade health check to inform users when they are doing something that WILL impact stability
### Fixed
- Do not allow deleting ffmpeg profiles that are used by channels
- Do not allow deleting default ffmpeg profile
- Allow ffmpeg profiles using VAAPI accel to set h264 video profile
- Fix HLS Direct playback, and make it accessible on separate streaming port
- Fix playback troubleshooting when using multiple watermarks or multiple graphics elements
### Changed
- Use table instead of tree view on blocks page
- Use different release packaging system to workaround false positive from Windows Defender
## [25.7.0] - 2025-10-03
### Added ### Added
- Add new collection type `Rerun Collection` - Add new collection type `Rerun Collection`
- This collection type will show up as *two* collection types in classic schedules - This collection type will show up as *two* collection types in classic schedules
@@ -2806,7 +2888,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker. - Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.0...HEAD [Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...HEAD
[25.8.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...v25.8.0
[25.7.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.0...v25.7.1
[25.7.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.6.0...v25.7.0 [25.7.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.6.0...v25.7.0
[25.6.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.5.0...v25.6.0 [25.6.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.5.0...v25.6.0
[25.5.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.4.0...v25.5.0 [25.5.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.4.0...v25.5.0
@@ -46,247 +46,265 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken) public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
{ {
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber); try
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int hiddenCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
.CountAsync(cancellationToken);
if (hiddenCount > 0)
{ {
File.Delete(targetFile); _logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
return;
}
string movieTemplateFileName = GetMovieTemplateFileName(); _localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null)
{
return;
}
var minifier = new XmlMinifier( string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
new XmlMinificationSettings await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int hiddenCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
.CountAsync(cancellationToken);
if (hiddenCount > 0)
{ {
MinifyWhitespace = true, File.Delete(targetFile);
RemoveXmlComments = true, return;
CollapseTagsWithoutContent = true
});
var templateContext = new XmlTemplateContext();
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
var songTemplate = Template.Parse(songText, songTemplateFileName);
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
TimeSpan playoutOffset = TimeSpan.Zero;
string mirrorChannelNumber = null;
Option<Channel> maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.MirrorSourceChannel)
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null)
.SelectOneAsync(c => c.Number == request.ChannelNumber, c => c.Number == request.ChannelNumber, cancellationToken);
foreach (Channel channel in maybeChannel)
{
mirrorChannelNumber = channel.MirrorSourceChannel.Number;
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero;
}
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
int daysToBuild = await _configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
foreach (Playout playout in playouts)
{
switch (playout.ScheduleKind)
{
case PlayoutScheduleKind.Classic:
case PlayoutScheduleKind.Sequential:
case PlayoutScheduleKind.Scripted:
var floodSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in floodSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WritePlayoutXml(
request,
floodSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.Block:
var blockSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in blockSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WriteBlockPlayoutXml(
request,
blockSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.ExternalJson:
var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile))
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in externalJsonSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WritePlayoutXml(
request,
externalJsonSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml,
cancellationToken);
break;
} }
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null)
{
return;
}
var minifier = new XmlMinifier(
new XmlMinificationSettings
{
MinifyWhitespace = true,
RemoveXmlComments = true,
CollapseTagsWithoutContent = true
});
var templateContext = new XmlTemplateContext();
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
var songTemplate = Template.Parse(songText, songTemplateFileName);
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
TimeSpan playoutOffset = TimeSpan.Zero;
string mirrorChannelNumber = null;
Option<Channel> maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.MirrorSourceChannel)
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null)
.SelectOneAsync(
c => c.Number == request.ChannelNumber,
c => c.Number == request.ChannelNumber,
cancellationToken);
foreach (Channel channel in maybeChannel)
{
mirrorChannelNumber = channel.MirrorSourceChannel.Number;
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero;
}
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
int daysToBuild = await _configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
foreach (Playout playout in playouts)
{
switch (playout.ScheduleKind)
{
case PlayoutScheduleKind.Classic:
case PlayoutScheduleKind.Sequential:
case PlayoutScheduleKind.Scripted:
var floodSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in floodSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WritePlayoutXml(
request,
floodSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.Block:
var blockSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in blockSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WriteBlockPlayoutXml(
request,
blockSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.ExternalJson:
var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile))
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in externalJsonSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WritePlayoutXml(
request,
externalJsonSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml,
cancellationToken);
break;
}
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
File.Move(tempFile, targetFile, true);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
} }
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
File.Move(tempFile, targetFile, true);
} }
private async Task WritePlayoutXml( private async Task WritePlayoutXml(
@@ -653,6 +671,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
showMetadata.Guids ??= []; showMetadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(showMetadata); string artworkPath = GetPrioritizedArtworkPath(showMetadata);
string thumbnailPath = GetPrioritizedArtworkPath(metadata);
var data = new var data = new
{ {
@@ -673,6 +692,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n), ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath), EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
EpisodeArtworkUrl = artworkPath, EpisodeArtworkUrl = artworkPath,
EpisodeHasThumbnail = !string.IsNullOrWhiteSpace(thumbnailPath),
EpisodeThumbnailUrl = thumbnailPath,
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0, SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
metadata.EpisodeNumber, metadata.EpisodeNumber,
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating), ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
-2
View File
@@ -81,8 +81,6 @@ internal static class Mapper
StreamingMode.TransportStreamHybrid => "MPEG-TS", StreamingMode.TransportStreamHybrid => "MPEG-TS",
StreamingMode.HttpLiveStreamingDirect => "HLS Direct", StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter", StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
StreamingMode.HttpLiveStreamingSegmenterFmp4 => "HLS Segmenter (fmp4)",
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
_ => throw new ArgumentOutOfRangeException(nameof(channel)) _ => throw new ArgumentOutOfRangeException(nameof(channel))
}; };
} }
@@ -5,17 +5,14 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>> public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
: IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
{ {
private readonly IChannelRepository _channelRepository;
public GetAllChannelsForApiHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public async Task<List<ChannelResponseModel>> Handle( public async Task<List<ChannelResponseModel>> Handle(
GetAllChannelsForApi request, GetAllChannelsForApi request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll(cancellationToken)).Flatten(); IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
return channels.Map(ProjectToResponseModel).ToList(); return channels.Map(ProjectToResponseModel).ToList();
} }
} }
@@ -4,15 +4,11 @@ using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, ChannelPlaylist> public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
{ {
private readonly IChannelRepository _channelRepository;
public GetChannelPlaylistHandler(IChannelRepository channelRepository) =>
_channelRepository = channelRepository;
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) => public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll(cancellationToken) channelRepository.GetAll(cancellationToken)
.Map(channels => EnsureMode(channels, request.Mode)) .Map(channels => EnsureMode(channels, request.Mode))
.Map(channels => new ChannelPlaylist( .Map(channels => new ChannelPlaylist(
request.Scheme, request.Scheme,
@@ -38,14 +34,6 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter; channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel); result.Add(channel);
break; break;
case "segmenter-fmp4":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterFmp4;
result.Add(channel);
break;
case "segmenter-v2":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterV2;
result.Add(channel);
break;
case "hls-direct": case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect; channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel); result.Add(channel);
@@ -1,9 +1,10 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -14,21 +15,23 @@ namespace ErsatzTV.Application.Emby;
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>, public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>> IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallEmbyCollectionScannerHandler( public CallEmbyCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator, IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
public async Task<Either<BaseError, Unit>> public async Task<Either<BaseError, Unit>>
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken) Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), parameters => PerformScan(parameters, request, cancellationToken),
error => error =>
{ {
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>()) foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -40,7 +43,7 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
}); });
} }
protected override async Task<DateTimeOffset> GetLastScan( protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
SynchronizeEmbyCollections request, SynchronizeEmbyCollections request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -49,7 +52,7 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId, cancellationToken) .SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId, cancellationToken)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc); .Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero); return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
} }
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
@@ -67,20 +70,40 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
} }
private async Task<Either<BaseError, Unit>> PerformScan( private async Task<Either<BaseError, Unit>> PerformScan(
string scanner, ScanParameters parameters,
SynchronizeEmbyCollections request, SynchronizeEmbyCollections request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.EmbyCollections);
foreach (var scanId in maybeScanId)
{ {
"scan-emby-collections", request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture) try
}; {
var arguments = new List<string>
{
"scan-emby-collections",
request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan) if (request.ForceScan)
{ {
arguments.Add("--force"); arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default); return BaseError.New("Emby collections are already scanning");
} }
} }
@@ -1,8 +1,9 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -15,14 +16,16 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>, IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>> IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallEmbyLibraryScannerHandler( public CallEmbyLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle( Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle(
@@ -38,9 +41,9 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
ISynchronizeEmbyLibraryById request, ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), parameters => PerformScan(parameters, request, cancellationToken),
error => error =>
{ {
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>()) foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -53,38 +56,58 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
} }
private async Task<Either<BaseError, string>> PerformScan( private async Task<Either<BaseError, string>> PerformScan(
string scanner, ScanParameters parameters,
ISynchronizeEmbyLibraryById request, ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
foreach (var scanId in maybeScanId)
{ {
"scan-emby", request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture) try
}; {
var arguments = new List<string>
{
"scan-emby",
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan) if (request.ForceScan)
{ {
arguments.Add("--force"); arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
if (request.DeepScan) return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
} }
protected override async Task<DateTimeOffset> GetLastScan( protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
ISynchronizeEmbyLibraryById request, ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
DateTime minDateTime = await dbContext.EmbyLibraries Option<EmbyLibrary> maybeLibrary = await dbContext.EmbyLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken) .SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken);
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero); DateTime minDateTime = maybeLibrary.Match(
l => l.LastScan ?? SystemTime.MinValueUtc,
() => SystemTime.MaxValueUtc);
string libraryName = maybeLibrary.Match(l => l.Name, string.Empty);
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
} }
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
@@ -1,8 +1,8 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -13,14 +13,16 @@ namespace ErsatzTV.Application.Emby;
public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyShowById>, public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyShowById>,
IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallEmbyShowScannerHandler( public CallEmbyShowScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle( Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle(
@@ -31,9 +33,9 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
SynchronizeEmbyShowById request, SynchronizeEmbyShowById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), parameters => PerformScan(parameters, request, cancellationToken),
error => error =>
{ {
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>()) foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -46,30 +48,44 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
} }
private async Task<Either<BaseError, string>> PerformScan( private async Task<Either<BaseError, string>> PerformScan(
string scanner, ScanParameters parameters,
SynchronizeEmbyShowById request, SynchronizeEmbyShowById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
foreach (var scanId in maybeScanId)
{ {
"scan-emby-show", try
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture), {
request.ShowId.ToString(CultureInfo.InvariantCulture) var arguments = new List<string>
}; {
"scan-emby-show",
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
request.ShowId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.DeepScan) if (request.DeepScan)
{ {
arguments.Add("--deep"); arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
return await base.PerformScan(scanner, arguments, cancellationToken); return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
} }
protected override Task<DateTimeOffset> GetLastScan( protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
SynchronizeEmbyShowById request, SynchronizeEmbyShowById request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
Task.FromResult(DateTimeOffset.MinValue); Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
DateTimeOffset lastScan, DateTimeOffset lastScan,
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.Caching.Memory;
namespace ErsatzTV.Application.Emby; namespace ErsatzTV.Application.Emby;
@@ -12,16 +13,19 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
private readonly IEmbyApiClient _embyApiClient; private readonly IEmbyApiClient _embyApiClient;
private readonly IEmbySecretStore _embySecretStore; private readonly IEmbySecretStore _embySecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
public SaveEmbySecretsHandler( public SaveEmbySecretsHandler(
IEmbySecretStore embySecretStore, IEmbySecretStore embySecretStore,
IEmbyApiClient embyApiClient, IEmbyApiClient embyApiClient,
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IMemoryCache memoryCache,
ChannelWriter<IEmbyBackgroundServiceRequest> channel) ChannelWriter<IEmbyBackgroundServiceRequest> channel)
{ {
_embySecretStore = embySecretStore; _embySecretStore = embySecretStore;
_embyApiClient = embyApiClient; _embyApiClient = embyApiClient;
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_memoryCache = memoryCache;
_channel = channel; _channel = channel;
} }
@@ -47,6 +51,7 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
parameters.Secrets.Address, parameters.Secrets.Address,
parameters.ServerInformation.ServerName, parameters.ServerInformation.ServerName,
parameters.ServerInformation.OperatingSystem); parameters.ServerInformation.OperatingSystem);
_memoryCache.Remove(new GetEmbyConnectionParameters());
await _channel.WriteAsync(new SynchronizeEmbyMediaSources()); await _channel.WriteAsync(new SynchronizeEmbyMediaSources());
return Unit.Default; return Unit.Default;
@@ -2,5 +2,6 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Emby; namespace ErsatzTV.Application.Emby;
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>, public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan)
IScannerBackgroundServiceRequest; : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;
@@ -14,8 +14,8 @@
<PackageReference Include="CliWrap" Version="3.9.0" /> <PackageReference Include="CliWrap" Version="3.9.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" /> <PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="[12.5.0]" /> <PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.9" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -1,5 +1,6 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -7,23 +8,18 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles; namespace ErsatzTV.Application.FFmpegProfiles;
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>> public class DeleteFFmpegProfileHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ISearchTargets searchTargets)
: IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle( public async Task<Either<BaseError, Unit>> Handle(
DeleteFFmpegProfile request, DeleteFFmpegProfile request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request, cancellationToken); Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => DoDeletion(dbContext, p)); return await validation.Apply(p => DoDeletion(dbContext, p));
} }
@@ -31,10 +27,19 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
{ {
dbContext.FFmpegProfiles.Remove(ffmpegProfile); dbContext.FFmpegProfiles.Remove(ffmpegProfile);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged(); searchTargets.SearchTargetsChanged();
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext,
DeleteFFmpegProfile request,
CancellationToken cancellationToken) =>
(await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken),
await FFmpegProfileMustNotBeDefault(request, cancellationToken),
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
.Apply((_, _, ffmpegProfile) => ffmpegProfile);
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist( private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext, TvContext dbContext,
DeleteFFmpegProfile request, DeleteFFmpegProfile request,
@@ -42,4 +47,38 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
dbContext.FFmpegProfiles dbContext.FFmpegProfiles
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken) .SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist")); .Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed(
TvContext dbContext,
DeleteFFmpegProfile request,
CancellationToken cancellationToken)
{
int count = await dbContext.Channels
.AsNoTracking()
.Where(c => c.FFmpegProfileId == request.FFmpegProfileId)
.CountAsync(cancellationToken);
if (count > 0)
{
return BaseError.New(
$"Cannot delete FFmpeg Profile that is used by {count} {(count > 1 ? "channels" : "channel")}");
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeDefault(
DeleteFFmpegProfile request,
CancellationToken cancellationToken)
{
Option<int> defaultFFmpegProfileId =
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
if (defaultFFmpegProfileId.Any(id => id == request.FFmpegProfileId))
{
return BaseError.New("Cannot delete default FFmpeg Profile");
}
return Unit.Default;
}
} }
@@ -2,8 +2,6 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.Preset; using ErsatzTV.FFmpeg.Preset;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -11,23 +9,14 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles; namespace ErsatzTV.Application.FFmpegProfiles;
public class public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>> : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle( public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
UpdateFFmpegProfile request, UpdateFFmpegProfile request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken); Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken)); return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
} }
@@ -89,7 +78,7 @@ public class
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged(); searchTargets.SearchTargetsChanged();
return new UpdateFFmpegProfileResult(p.Id); return new UpdateFFmpegProfileResult(p.Id);
} }
@@ -98,7 +87,8 @@ public class
TvContext dbContext, TvContext dbContext,
UpdateFFmpegProfile request, UpdateFFmpegProfile request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
(await FFmpegProfileMustExist(dbContext, request, cancellationToken), ValidateName(request), (await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await ValidateName(dbContext, request),
ValidateThreadCount(request), ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken)) await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate); .Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
@@ -111,9 +101,25 @@ public class
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken) .SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist.")); .Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
private static Validation<BaseError, string> ValidateName(UpdateFFmpegProfile updateFFmpegProfile) => private static async Task<Validation<BaseError, string>> ValidateName(
updateFFmpegProfile.NotEmpty(x => x.Name) TvContext dbContext,
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name)); UpdateFFmpegProfile updateFFmpegProfile)
{
if (updateFFmpegProfile.Name.Length > 50)
{
return BaseError.New($"FFmpeg profile name \"{updateFFmpegProfile.Name}\" is invalid");
}
Option<FFmpegProfile> maybeExisting = await dbContext.FFmpegProfiles
.AsNoTracking()
.FirstOrDefaultAsync(ff =>
ff.Id != updateFFmpegProfile.FFmpegProfileId && ff.Name == updateFFmpegProfile.Name)
.Map(Optional);
return maybeExisting.IsSome
? BaseError.New($"An ffmpeg profile named \"{updateFFmpegProfile.Name}\" already exists in the database")
: Success<BaseError, string>(updateFFmpegProfile.Name);
}
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) => private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount); updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
+13 -7
View File
@@ -47,19 +47,25 @@ internal static class Mapper
ffmpegProfile.Id, ffmpegProfile.Id,
ffmpegProfile.Name, ffmpegProfile.Name,
ffmpegProfile.ThreadCount, ffmpegProfile.ThreadCount,
(int)ffmpegProfile.HardwareAcceleration, ffmpegProfile.HardwareAcceleration,
ffmpegProfile.VaapiDisplay, ffmpegProfile.VaapiDisplay,
(int)ffmpegProfile.VaapiDriver, ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice, ffmpegProfile.VaapiDevice,
ffmpegProfile.ResolutionId, ffmpegProfile.QsvExtraHardwareFrames,
(int)ffmpegProfile.VideoFormat, ffmpegProfile.Resolution.Name,
ffmpegProfile.ScalingBehavior,
ffmpegProfile.VideoFormat,
ffmpegProfile.VideoProfile,
ffmpegProfile.VideoPreset,
ffmpegProfile.AllowBFrames,
ffmpegProfile.BitDepth,
ffmpegProfile.VideoBitrate, ffmpegProfile.VideoBitrate,
ffmpegProfile.VideoBufferSize, ffmpegProfile.VideoBufferSize,
(int)ffmpegProfile.TonemapAlgorithm, ffmpegProfile.TonemapAlgorithm,
(int)ffmpegProfile.AudioFormat, ffmpegProfile.AudioFormat,
ffmpegProfile.AudioBitrate, ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize, ffmpegProfile.AudioBufferSize,
(int)ffmpegProfile.NormalizeLoudnessMode, ffmpegProfile.NormalizeLoudnessMode,
ffmpegProfile.AudioChannels, ffmpegProfile.AudioChannels,
ffmpegProfile.AudioSampleRate, ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate, ffmpegProfile.NormalizeFramerate,
@@ -2,4 +2,4 @@
namespace ErsatzTV.Application.FFmpegProfiles; namespace ErsatzTV.Application.FFmpegProfiles;
public record GetAllFFmpegProfilesForApi : IRequest<List<FFmpegProfileResponseModel>>; public record GetAllFFmpegProfilesForApi : IRequest<List<FFmpegFullProfileResponseModel>>;
@@ -6,23 +6,18 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles; namespace ErsatzTV.Application.FFmpegProfiles;
public class public class GetAllFFmpegProfilesForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
GetAllFFmpegProfilesForApiHandler : IRequestHandler<GetAllFFmpegProfilesForApi, List<FFmpegProfileResponseModel>> : IRequestHandler<GetAllFFmpegProfilesForApi, List<FFmpegFullProfileResponseModel>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; public async Task<List<FFmpegFullProfileResponseModel>> Handle(
public GetAllFFmpegProfilesForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<FFmpegProfileResponseModel>> Handle(
GetAllFFmpegProfilesForApi request, GetAllFFmpegProfilesForApi request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<FFmpegProfile> ffmpegProfiles = await dbContext.FFmpegProfiles List<FFmpegProfile> ffmpegProfiles = await dbContext.FFmpegProfiles
.AsNoTracking() .AsNoTracking()
.Include(p => p.Resolution) .Include(p => p.Resolution)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return ffmpegProfiles.Map(ProjectToResponseModel).ToList(); return ffmpegProfiles.Map(ProjectToFullResponseModel).ToList();
} }
} }
@@ -1,9 +1,10 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -14,21 +15,23 @@ namespace ErsatzTV.Application.Jellyfin;
public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinCollections>, public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinCollections>,
IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>> IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallJellyfinCollectionScannerHandler( public CallJellyfinCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator, IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
public async Task<Either<BaseError, Unit>> public async Task<Either<BaseError, Unit>>
Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken) Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), parameters => PerformScan(parameters, request, cancellationToken),
error => error =>
{ {
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>()) foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -40,7 +43,7 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
}); });
} }
protected override async Task<DateTimeOffset> GetLastScan( protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
SynchronizeJellyfinCollections request, SynchronizeJellyfinCollections request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -49,7 +52,7 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId, cancellationToken) .SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId, cancellationToken)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc); .Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero); return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
} }
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
@@ -67,20 +70,40 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
} }
private async Task<Either<BaseError, Unit>> PerformScan( private async Task<Either<BaseError, Unit>> PerformScan(
string scanner, ScanParameters parameters,
SynchronizeJellyfinCollections request, SynchronizeJellyfinCollections request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.JellyfinCollections);
foreach (var scanId in maybeScanId)
{ {
"scan-jellyfin-collections", request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture) try
}; {
var arguments = new List<string>
{
"scan-jellyfin-collections",
request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId),
};
if (request.ForceScan) if (request.ForceScan)
{ {
arguments.Add("--force"); arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default); return BaseError.New("Jellyfin collections are already scanning");
} }
} }
@@ -1,8 +1,9 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -15,14 +16,16 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>, IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallJellyfinLibraryScannerHandler( public CallJellyfinLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>. Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>.
@@ -39,9 +42,9 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
ISynchronizeJellyfinLibraryById request, ISynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), parameters => PerformScan(parameters, request, cancellationToken),
error => error =>
{ {
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>()) foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -54,38 +57,58 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
} }
private async Task<Either<BaseError, string>> PerformScan( private async Task<Either<BaseError, string>> PerformScan(
string scanner, ScanParameters parameters,
ISynchronizeJellyfinLibraryById request, ISynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.JellyfinLibraryId);
foreach (var scanId in maybeScanId)
{ {
"scan-jellyfin", request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture) try
}; {
var arguments = new List<string>
{
"scan-jellyfin",
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan) if (request.ForceScan)
{ {
arguments.Add("--force"); arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
if (request.DeepScan) return BaseError.New($"Library {request.JellyfinLibraryId} is already scanning");
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
} }
protected override async Task<DateTimeOffset> GetLastScan( protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
ISynchronizeJellyfinLibraryById request, ISynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
DateTime minDateTime = await dbContext.JellyfinLibraries Option<JellyfinLibrary> maybeLibrary = await dbContext.JellyfinLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId, cancellationToken) .SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId, cancellationToken);
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero); DateTime minDateTime = maybeLibrary.Match(
l => l.LastScan ?? SystemTime.MinValueUtc,
() => SystemTime.MaxValueUtc);
string libraryName = maybeLibrary.Match(l => l.Name, string.Empty);
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
} }
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
@@ -1,8 +1,8 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -13,14 +13,16 @@ namespace ErsatzTV.Application.Jellyfin;
public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinShowById>, public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinShowById>,
IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallJellyfinShowScannerHandler( public CallJellyfinShowScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>.Handle( Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>.Handle(
@@ -31,9 +33,9 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
SynchronizeJellyfinShowById request, SynchronizeJellyfinShowById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), parameters => PerformScan(parameters, request, cancellationToken),
error => error =>
{ {
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>()) foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -46,30 +48,44 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
} }
private async Task<Either<BaseError, string>> PerformScan( private async Task<Either<BaseError, string>> PerformScan(
string scanner, ScanParameters parameters,
SynchronizeJellyfinShowById request, SynchronizeJellyfinShowById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.JellyfinLibraryId);
foreach (var scanId in maybeScanId)
{ {
"scan-jellyfin-show", try
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture), {
request.ShowId.ToString(CultureInfo.InvariantCulture) var arguments = new List<string>
}; {
"scan-jellyfin-show",
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
request.ShowId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.DeepScan) if (request.DeepScan)
{ {
arguments.Add("--deep"); arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
return await base.PerformScan(scanner, arguments, cancellationToken); return BaseError.New($"Library {request.JellyfinLibraryId} is already scanning");
} }
protected override Task<DateTimeOffset> GetLastScan( protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
SynchronizeJellyfinShowById request, SynchronizeJellyfinShowById request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
Task.FromResult(DateTimeOffset.MinValue); Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
DateTimeOffset lastScan, DateTimeOffset lastScan,
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using Microsoft.Extensions.Caching.Memory;
namespace ErsatzTV.Application.Jellyfin; namespace ErsatzTV.Application.Jellyfin;
@@ -12,16 +13,19 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
private readonly IJellyfinApiClient _jellyfinApiClient; private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinSecretStore _jellyfinSecretStore; private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
public SaveJellyfinSecretsHandler( public SaveJellyfinSecretsHandler(
IJellyfinSecretStore jellyfinSecretStore, IJellyfinSecretStore jellyfinSecretStore,
IJellyfinApiClient jellyfinApiClient, IJellyfinApiClient jellyfinApiClient,
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IMemoryCache memoryCache,
ChannelWriter<IJellyfinBackgroundServiceRequest> channel) ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
{ {
_jellyfinSecretStore = jellyfinSecretStore; _jellyfinSecretStore = jellyfinSecretStore;
_jellyfinApiClient = jellyfinApiClient; _jellyfinApiClient = jellyfinApiClient;
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_memoryCache = memoryCache;
_channel = channel; _channel = channel;
} }
@@ -47,6 +51,7 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
parameters.Secrets.Address, parameters.Secrets.Address,
parameters.ServerInformation.ServerName, parameters.ServerInformation.ServerName,
parameters.ServerInformation.OperatingSystem); parameters.ServerInformation.OperatingSystem);
_memoryCache.Remove(new GetJellyfinConnectionParameters());
await _channel.WriteAsync(new SynchronizeJellyfinMediaSources()); await _channel.WriteAsync(new SynchronizeJellyfinMediaSources());
return Unit.Default; return Unit.Default;
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin; namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) : public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan) :
IRequest<Either<BaseError, Unit>>, IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest; IScannerBackgroundServiceRequest;
@@ -1,17 +1,12 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Channels;
using CliWrap; using CliWrap;
using ErsatzTV.Application.Search;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Serilog; using Serilog;
using Serilog.Core; using Serilog.Core;
using Serilog.Events; using Serilog.Events;
@@ -19,34 +14,15 @@ using Serilog.Formatting.Compact.Reader;
namespace ErsatzTV.Application.Libraries; namespace ErsatzTV.Application.Libraries;
public abstract class CallLibraryScannerHandler<TRequest> public abstract class CallLibraryScannerHandler<TRequest>(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
IRuntimeInfo runtimeInfo)
{ {
private readonly int _batchSize = 100; protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/scan/{scanId}";
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediator _mediator;
private readonly IRuntimeInfo _runtimeInfo;
private readonly List<int> _toReindex = [];
private readonly List<int> _toRemove = [];
private string _libraryName;
protected CallLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
{
_dbContextFactory = dbContextFactory;
_configElementRepository = configElementRepository;
_channel = channel;
_mediator = mediator;
_runtimeInfo = runtimeInfo;
}
protected async Task<Either<BaseError, string>> PerformScan( protected async Task<Either<BaseError, string>> PerformScan(
string scanner, ScanParameters parameters,
List<string> arguments, List<string> arguments,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -55,39 +31,26 @@ public abstract class CallLibraryScannerHandler<TRequest>
using var forcefulCts = new CancellationTokenSource(); using var forcefulCts = new CancellationTokenSource();
await using CancellationTokenRegistration link = await using CancellationTokenRegistration link =
cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10)) cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10)));
);
CommandResult process = await Cli.Wrap(scanner) CommandResult process = await Cli.Wrap(parameters.Scanner)
.WithArguments(arguments) .WithArguments(arguments)
.WithValidation(CommandResultValidation.None) .WithValidation(CommandResultValidation.None)
.WithStandardErrorPipe(PipeTarget.ToDelegate(ProcessLogOutput)) .WithStandardErrorPipe(PipeTarget.ToDelegate(ProcessLogOutput))
.WithStandardOutputPipe(PipeTarget.ToDelegate(ProcessProgressOutput)) .WithStandardOutputPipe(PipeTarget.Null)
.ExecuteAsync(forcefulCts.Token, cancellationToken); .ExecuteAsync(forcefulCts.Token, cancellationToken);
if (process.ExitCode != 0) if (process.ExitCode != 0)
{ {
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}"); return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
} }
if (_toReindex.Count > 0)
{
await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray()), cancellationToken);
_toReindex.Clear();
}
if (_toRemove.Count > 0)
{
await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray()), cancellationToken);
_toRemove.Clear();
}
} }
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{ {
// do nothing // do nothing
} }
return _libraryName ?? string.Empty; return parameters.LibraryName;
} }
private static void ProcessLogOutput(string s) private static void ProcessLogOutput(string s)
@@ -124,94 +87,59 @@ public abstract class CallLibraryScannerHandler<TRequest>
} }
} }
private async Task ProcessProgressOutput(string s) protected abstract Task<Tuple<string, DateTimeOffset>> GetLastScan(
{
if (!string.IsNullOrWhiteSpace(s))
{
try
{
ScannerProgressUpdate progressUpdate = JsonConvert.DeserializeObject<ScannerProgressUpdate>(s);
if (progressUpdate != null)
{
if (!string.IsNullOrWhiteSpace(progressUpdate.LibraryName))
{
_libraryName = progressUpdate.LibraryName;
}
_toReindex.AddRange(progressUpdate.ItemsToReindex);
if (_toReindex.Count >= _batchSize)
{
await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray()));
_toReindex.Clear();
}
_toRemove.AddRange(progressUpdate.ItemsToRemove);
if (_toRemove.Count >= _batchSize)
{
await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray()));
_toRemove.Clear();
}
if (progressUpdate.PercentComplete is not null)
{
var progress = new LibraryScanProgress(
progressUpdate.LibraryId,
progressUpdate.PercentComplete.Value);
await _mediator.Publish(progress);
}
}
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Unable to process scanner progress update");
}
}
}
protected abstract Task<DateTimeOffset> GetLastScan(
TvContext dbContext, TvContext dbContext,
TRequest request, TRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request); protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
protected async Task<Validation<BaseError, string>> Validate(TRequest request, CancellationToken cancellationToken) protected async Task<Validation<BaseError, ScanParameters>> Validate(TRequest request, CancellationToken cancellationToken)
{ {
int libraryRefreshInterval = await _configElementRepository try
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
.IfNoneAsync(0);
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
DateTimeOffset lastScan = await GetLastScan(dbContext, request, cancellationToken);
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
{ {
return new ScanIsNotRequired(); int libraryRefreshInterval = await configElementRepository
} .GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
.IfNoneAsync(0);
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows) libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
? "ErsatzTV.Scanner.exe"
: "ErsatzTV.Scanner";
string processFileName = Environment.ProcessPath ?? string.Empty; await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
string processExecutable = Path.GetFileNameWithoutExtension(processFileName);
string folderName = Path.GetDirectoryName(processFileName);
if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase))
{
folderName = AppContext.BaseDirectory;
}
if (!string.IsNullOrWhiteSpace(folderName)) (string libraryName, DateTimeOffset lastScan) = await GetLastScan(dbContext, request, cancellationToken);
{ if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
string localFileName = Path.Combine(folderName, executable);
if (File.Exists(localFileName))
{ {
return localFileName; return new ScanIsNotRequired();
} }
}
return BaseError.New("Unable to locate ErsatzTV.Scanner executable"); string executable = runtimeInfo.IsOSPlatform(OSPlatform.Windows)
? "ErsatzTV.Scanner.exe"
: "ErsatzTV.Scanner";
string processFileName = Environment.ProcessPath ?? string.Empty;
string processExecutable = Path.GetFileNameWithoutExtension(processFileName);
string folderName = Path.GetDirectoryName(processFileName);
if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase))
{
folderName = AppContext.BaseDirectory;
}
if (!string.IsNullOrWhiteSpace(folderName))
{
string localFileName = Path.Combine(folderName, executable);
if (File.Exists(localFileName))
{
return new ScanParameters(libraryName, localFileName);
}
}
return BaseError.New("Unable to locate ErsatzTV.Scanner executable");
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return BaseError.New("Scan was canceled");
}
} }
protected sealed record ScanParameters(string LibraryName, string Scanner);
} }
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record CreateSmartCollectionResult(int SmartCollectionId) : EntityIdResult(SmartCollectionId);
@@ -1,5 +1,7 @@
using ErsatzTV.Application.Scheduling; using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public record PreviewPlaylistPlayout(ReplacePlaylistItems Data) : IRequest<List<PlayoutItemPreviewViewModel>>; public record PreviewPlaylistPlayout(ReplacePlaylistItems Data)
: IRequest<Either<BaseError, List<PlayoutItemPreviewViewModel>>>;
@@ -17,9 +17,9 @@ public class PreviewPlaylistPlayoutHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
IPlayoutBuilder playoutBuilder) IPlayoutBuilder playoutBuilder)
: IRequestHandler<PreviewPlaylistPlayout, List<PlayoutItemPreviewViewModel>> : IRequestHandler<PreviewPlaylistPlayout, Either<BaseError, List<PlayoutItemPreviewViewModel>>>
{ {
public async Task<List<PlayoutItemPreviewViewModel>> Handle( public async Task<Either<BaseError, List<PlayoutItemPreviewViewModel>>> Handle(
PreviewPlaylistPlayout request, PreviewPlaylistPlayout request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -65,7 +65,7 @@ public class PreviewPlaylistPlayoutHandler(
PlayoutBuildMode.Reset, PlayoutBuildMode.Reset,
cancellationToken); cancellationToken);
return await buildResult.MatchAsync( return await buildResult.MatchAsync<Either<BaseError, List<PlayoutItemPreviewViewModel>>>(
async result => async result =>
{ {
var maxItems = 0; var maxItems = 0;
@@ -121,7 +121,7 @@ public class PreviewPlaylistPlayoutHandler(
return onceThrough.OrderBy(i => i.StartOffset).Map(Scheduling.Mapper.ProjectToViewModel).ToList(); return onceThrough.OrderBy(i => i.StartOffset).Map(Scheduling.Mapper.ProjectToViewModel).ToList();
}, },
_ => []); error => error);
} }
private static ProgramScheduleItemFlood MapToScheduleItem(PreviewPlaylistPlayout request) => private static ProgramScheduleItemFlood MapToScheduleItem(PreviewPlaylistPlayout request) =>
@@ -195,7 +195,8 @@ public abstract class TraktCommandBase
Index = item.Rank, Index = item.Rank,
PlaylistId = list.Playlist.Id, PlaylistId = list.Playlist.Id,
Playlist = list.Playlist, Playlist = list.Playlist,
IncludeInProgramGuide = true IncludeInProgramGuide = true,
PlaybackOrder = PlaybackOrder.Chronological
}; };
await dbContext.PlaylistItems.AddAsync(playlistItem, cancellationToken); await dbContext.PlaylistItems.AddAsync(playlistItem, cancellationToken);
@@ -2,4 +2,5 @@
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public record UpdateSmartCollection(int Id, string Name, string Query) : IRequest<Either<BaseError, Unit>>; public record UpdateSmartCollection(int Id, string Name, string Query)
: IRequest<Either<BaseError, UpdateSmartCollectionResult>>;
@@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>> public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, UpdateSmartCollectionResult>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -34,7 +34,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
_smartCollectionCache = smartCollectionCache; _smartCollectionCache = smartCollectionCache;
} }
public async Task<Either<BaseError, Unit>> Handle( public async Task<Either<BaseError, UpdateSmartCollectionResult>> Handle(
UpdateSmartCollection request, UpdateSmartCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -43,7 +43,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken)); return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
} }
private async Task<Unit> ApplyUpdateRequest( private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest(
TvContext dbContext, TvContext dbContext,
SmartCollection c, SmartCollection c,
UpdateSmartCollection request, UpdateSmartCollection request,
@@ -65,7 +65,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
} }
} }
return Unit.Default; return new UpdateSmartCollectionResult(c.Id);
} }
private static Task<Validation<BaseError, SmartCollection>> Validate( private static Task<Validation<BaseError, SmartCollection>> Validate(
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record UpdateSmartCollectionResult(int SmartCollectionId) : EntityIdResult(SmartCollectionId);
@@ -1,4 +1,5 @@
using ErsatzTV.Application.Tree; using ErsatzTV.Application.Tree;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
@@ -23,6 +24,9 @@ internal static class Mapper
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) => internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query); new(collection.Id, collection.Name, collection.Query);
internal static SmartCollectionResponseModel ProjectToResponseModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
internal static RerunCollectionViewModel ProjectToViewModel(RerunCollection collection) => internal static RerunCollectionViewModel ProjectToViewModel(RerunCollection collection) =>
new( new(
collection.Id, collection.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.SmartCollections;
namespace ErsatzTV.Application.MediaCollections;
public record GetAllSmartCollectionsForApi : IRequest<List<SmartCollectionResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections;
public class GetAllSmartCollectionsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllSmartCollectionsForApi, List<SmartCollectionResponseModel>>
{
public async Task<List<SmartCollectionResponseModel>> Handle(
GetAllSmartCollectionsForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<SmartCollection> ffmpegProfiles = await dbContext.SmartCollections
.AsNoTracking()
.ToListAsync(cancellationToken);
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
}
}
@@ -1,12 +1,13 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaSources; namespace ErsatzTV.Application.MediaSources;
@@ -15,14 +16,16 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>, IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>> IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallLocalLibraryScannerHandler( public CallLocalLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
Task<Either<BaseError, string>> IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>.Handle( Task<Either<BaseError, string>> IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>.Handle(
@@ -35,9 +38,9 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
private async Task<Either<BaseError, string>> Handle(IScanLocalLibrary request, CancellationToken cancellationToken) private async Task<Either<BaseError, string>> Handle(IScanLocalLibrary request, CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), parameters => PerformScan(parameters, request, cancellationToken),
error => error =>
{ {
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>()) foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -50,24 +53,39 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
} }
private async Task<Either<BaseError, string>> PerformScan( private async Task<Either<BaseError, string>> PerformScan(
string scanner, ScanParameters parameters,
IScanLocalLibrary request, IScanLocalLibrary request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.LibraryId);
foreach (var scanId in maybeScanId)
{ {
"scan-local", request.LibraryId.ToString(CultureInfo.InvariantCulture) try
}; {
var arguments = new List<string>
{
"scan-local",
request.LibraryId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan) if (request.ForceScan)
{ {
arguments.Add("--force"); arguments.Add("--force");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
return await base.PerformScan(scanner, arguments, cancellationToken); return BaseError.New($"Library {request.LibraryId} is already scanning");
} }
protected override async Task<DateTimeOffset> GetLastScan( protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
IScanLocalLibrary request, IScanLocalLibrary request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -80,7 +98,11 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
? libraryPaths.Min(lp => lp.LastScan ?? SystemTime.MinValueUtc) ? libraryPaths.Min(lp => lp.LastScan ?? SystemTime.MinValueUtc)
: SystemTime.MaxValueUtc; : SystemTime.MaxValueUtc;
return new DateTimeOffset(minDateTime, TimeSpan.Zero); string libraryName = await dbContext.Libraries
.SelectOneAsync(l => l.Id, l => l.Id == request.LibraryId, cancellationToken)
.Match(l => l.Name, () => string.Empty);
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
} }
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Channel = ErsatzTV.Core.Domain.Channel; using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
@@ -29,6 +30,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
private readonly IPlayoutBuilder _playoutBuilder; private readonly IPlayoutBuilder _playoutBuilder;
private readonly IPlayoutTimeShifter _playoutTimeShifter; private readonly IPlayoutTimeShifter _playoutTimeShifter;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly ILogger<BuildPlayoutHandler> _logger;
private readonly ISequentialPlayoutBuilder _sequentialPlayoutBuilder; private readonly ISequentialPlayoutBuilder _sequentialPlayoutBuilder;
private readonly IScriptedPlayoutBuilder _scriptedPlayoutBuilder; private readonly IScriptedPlayoutBuilder _scriptedPlayoutBuilder;
@@ -44,7 +46,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
IFFmpegSegmenterService ffmpegSegmenterService, IFFmpegSegmenterService ffmpegSegmenterService,
IEntityLocker entityLocker, IEntityLocker entityLocker,
IPlayoutTimeShifter playoutTimeShifter, IPlayoutTimeShifter playoutTimeShifter,
ChannelWriter<IBackgroundServiceRequest> workerChannel) ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<BuildPlayoutHandler> logger)
{ {
_client = client; _client = client;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
@@ -58,6 +61,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
_entityLocker = entityLocker; _entityLocker = entityLocker;
_playoutTimeShifter = playoutTimeShifter; _playoutTimeShifter = playoutTimeShifter;
_workerChannel = workerChannel; _workerChannel = workerChannel;
_logger = logger;
} }
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken) public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
@@ -88,6 +92,38 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{ {
await _playoutTimeShifter.TimeShift(request.PlayoutId, timeShiftTo, false, cancellationToken); await _playoutTimeShifter.TimeShift(request.PlayoutId, timeShiftTo, false, cancellationToken);
} }
if (playoutBuildResult.Warnings.TailFillerTooLong > 0)
{
_logger.LogDebug(
"Playout {PlayoutId} skipped {Count} tail filler items that were too long to fit",
request.PlayoutId,
playoutBuildResult.Warnings.TailFillerTooLong);
}
if (playoutBuildResult.Warnings.MidRollContentWithoutChapters > 0)
{
_logger.LogDebug(
"Playout {PlayoutId} converted mid-roll to post-roll for {Count} items that have no chapter markers",
request.PlayoutId,
playoutBuildResult.Warnings.MidRollContentWithoutChapters);
}
if (playoutBuildResult.Warnings.DurationFillerSkipped > 0)
{
_logger.LogDebug(
"Playout {PlayoutId} skipped {Count} filler items to try to fit in a small remaining duration",
request.PlayoutId,
playoutBuildResult.Warnings.DurationFillerSkipped);
}
if (playoutBuildResult.Warnings.BlockItemSkippedEmptyCollection > 0)
{
_logger.LogDebug(
"Playout {PlayoutId} skipped {Count} block items due to empty collections",
request.PlayoutId,
playoutBuildResult.Warnings.BlockItemSkippedEmptyCollection);
}
} }
return result.Map(_ => Unit.Default); return result.Map(_ => Unit.Default);
+9
View File
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
@@ -34,6 +35,14 @@ internal static class Mapper
programScheduleAlternate.DaysOfMonth, programScheduleAlternate.DaysOfMonth,
programScheduleAlternate.MonthsOfYear); programScheduleAlternate.MonthsOfYear);
internal static PlayoutHistoryViewModel ProjectToViewModel(PlayoutHistory playoutHistory) =>
new(
playoutHistory.Id,
new DateTimeOffset(playoutHistory.When, TimeSpan.Zero).ToLocalTime(),
new DateTimeOffset(playoutHistory.Finish, TimeSpan.Zero).ToLocalTime(),
playoutHistory.Key,
playoutHistory.Details);
internal static string GetDisplayTitle(MediaItem mediaItem, Option<string> maybeChapterTitle) internal static string GetDisplayTitle(MediaItem mediaItem, Option<string> maybeChapterTitle)
{ {
string chapterTitle = maybeChapterTitle.IfNone(string.Empty); string chapterTitle = maybeChapterTitle.IfNone(string.Empty);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record PagedPlayoutHistoryViewModel(int TotalCount, List<PlayoutHistoryViewModel> Page);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record PlayoutHistoryViewModel(int Id, DateTimeOffset When, DateTimeOffset Finish, string Key, string Details);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetAllBlockPlayouts : IRequest<List<PlayoutNameViewModel>>;
@@ -0,0 +1,26 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class GetAllBlockPlayoutsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllBlockPlayouts, List<PlayoutNameViewModel>>
{
public async Task<List<PlayoutNameViewModel>> Handle(
GetAllBlockPlayouts request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Include(p => p.Channel)
.Include(p => p.ProgramSchedule)
.Include(p => p.BuildStatus)
.Where(p => p.Channel != null && p.ScheduleKind == PlayoutScheduleKind.Block)
.ToListAsync(cancellationToken);
return playouts.Map(Mapper.ProjectToViewModel).ToList();
}
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Playouts;
public record GetBlockPlayoutHistory(int PlayoutId, int BlockId, int PageNum, int PageSize)
: IRequest<PagedPlayoutHistoryViewModel>;
@@ -0,0 +1,30 @@
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class GetBlockPlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetBlockPlayoutHistory, PagedPlayoutHistoryViewModel>
{
public async Task<PagedPlayoutHistoryViewModel> Handle(
GetBlockPlayoutHistory request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
IQueryable<PlayoutHistory> query = dbContext.PlayoutHistory
.AsNoTracking()
.Where(ph => ph.PlayoutId == request.PlayoutId && ph.BlockId == request.BlockId);
int totalCount = await query.CountAsync(cancellationToken);
List<PlayoutHistory> allHistory = await query
.OrderBy(ph => ph.Id)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken);
return new PagedPlayoutHistoryViewModel(totalCount, allHistory.Map(Mapper.ProjectToViewModel).ToList());
}
}
@@ -1,9 +1,10 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -14,21 +15,23 @@ namespace ErsatzTV.Application.Plex;
public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<SynchronizePlexCollections>, public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<SynchronizePlexCollections>,
IRequestHandler<SynchronizePlexCollections, Either<BaseError, Unit>> IRequestHandler<SynchronizePlexCollections, Either<BaseError, Unit>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallPlexCollectionScannerHandler( public CallPlexCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator, IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
public async Task<Either<BaseError, Unit>> public async Task<Either<BaseError, Unit>>
Handle(SynchronizePlexCollections request, CancellationToken cancellationToken) Handle(SynchronizePlexCollections request, CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), parameters => PerformScan(parameters, request, cancellationToken),
error => error =>
{ {
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>()) foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -40,7 +43,7 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
}); });
} }
protected override async Task<DateTimeOffset> GetLastScan( protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
SynchronizePlexCollections request, SynchronizePlexCollections request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -49,7 +52,7 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexMediaSourceId, cancellationToken) .SelectOneAsync(l => l.Id, l => l.Id == request.PlexMediaSourceId, cancellationToken)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc); .Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero); return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
} }
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
@@ -67,20 +70,40 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
} }
private async Task<Either<BaseError, Unit>> PerformScan( private async Task<Either<BaseError, Unit>> PerformScan(
string scanner, ScanParameters parameters,
SynchronizePlexCollections request, SynchronizePlexCollections request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.PlexCollections);
foreach (var scanId in maybeScanId)
{ {
"scan-plex-collections", request.PlexMediaSourceId.ToString(CultureInfo.InvariantCulture) try
}; {
var arguments = new List<string>
{
"scan-plex-collections",
request.PlexMediaSourceId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan) if (request.ForceScan)
{ {
arguments.Add("--force"); arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default); return BaseError.New("Plex collections are already scanning");
} }
} }
@@ -1,8 +1,9 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -15,14 +16,16 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>, IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>> IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallPlexLibraryScannerHandler( public CallPlexLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>.Handle( Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>.Handle(
@@ -38,9 +41,9 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
ISynchronizePlexLibraryById request, ISynchronizePlexLibraryById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), parameters => PerformScan(parameters, request, cancellationToken),
error => error =>
{ {
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>()) foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -53,38 +56,58 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
} }
private async Task<Either<BaseError, string>> PerformScan( private async Task<Either<BaseError, string>> PerformScan(
string scanner, ScanParameters parameters,
ISynchronizePlexLibraryById request, ISynchronizePlexLibraryById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.PlexLibraryId);
foreach (var scanId in maybeScanId)
{ {
"scan-plex", request.PlexLibraryId.ToString(CultureInfo.InvariantCulture) try
}; {
var arguments = new List<string>
{
"scan-plex",
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan) if (request.ForceScan)
{ {
arguments.Add("--force"); arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
if (request.DeepScan) return BaseError.New($"Library {request.PlexLibraryId} is already scanning");
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
} }
protected override async Task<DateTimeOffset> GetLastScan( protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
ISynchronizePlexLibraryById request, ISynchronizePlexLibraryById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
DateTime minDateTime = await dbContext.PlexLibraries Option<PlexLibrary> maybeLibrary = await dbContext.PlexLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId, cancellationToken) .SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId, cancellationToken);
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero); DateTime minDateTime = maybeLibrary.Match(
l => l.LastScan ?? SystemTime.MinValueUtc,
() => SystemTime.MaxValueUtc);
string libraryName = maybeLibrary.Match(l => l.Name, () => string.Empty);
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
} }
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
@@ -1,10 +1,11 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -15,19 +16,21 @@ namespace ErsatzTV.Application.Plex;
public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<SynchronizePlexNetworks>, public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<SynchronizePlexNetworks>,
IRequestHandler<SynchronizePlexNetworks, Either<BaseError, Unit>> IRequestHandler<SynchronizePlexNetworks, Either<BaseError, Unit>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallPlexNetworkScannerHandler( public CallPlexNetworkScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator, IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
public async Task<Either<BaseError, Unit>> public async Task<Either<BaseError, Unit>>
Handle(SynchronizePlexNetworks request, CancellationToken cancellationToken) Handle(SynchronizePlexNetworks request, CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), scanner => PerformScan(scanner, request, cancellationToken),
error => error =>
@@ -41,7 +44,7 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
}); });
} }
protected override async Task<DateTimeOffset> GetLastScan( protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
SynchronizePlexNetworks request, SynchronizePlexNetworks request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -51,7 +54,7 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId, cancellationToken) .SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId, cancellationToken)
.Match(l => l.LastNetworksScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc); .Match(l => l.LastNetworksScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero); return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
} }
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
@@ -69,20 +72,35 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
} }
private async Task<Either<BaseError, Unit>> PerformScan( private async Task<Either<BaseError, Unit>> PerformScan(
string scanner, ScanParameters parameters,
SynchronizePlexNetworks request, SynchronizePlexNetworks request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.PlexNetworks);
foreach (var scanId in maybeScanId)
{ {
"scan-plex-networks", request.PlexLibraryId.ToString(CultureInfo.InvariantCulture) try
}; {
var arguments = new List<string>
{
"scan-plex-networks",
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan) if (request.ForceScan)
{ {
arguments.Add("--force"); arguments.Add("--force");
}
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default); return BaseError.New("Plex networks are already scanning");
} }
} }
@@ -1,8 +1,8 @@
using System.Globalization; using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -13,14 +13,16 @@ namespace ErsatzTV.Application.Plex;
public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizePlexShowById>, public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizePlexShowById>,
IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>> IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>>
{ {
private readonly IScannerProxyService _scannerProxyService;
public CallPlexShowScannerHandler( public CallPlexShowScannerHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, IScannerProxyService scannerProxyService,
IMediator mediator,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
{ {
_scannerProxyService = scannerProxyService;
} }
Task<Either<BaseError, string>> IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>>.Handle( Task<Either<BaseError, string>> IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>>.Handle(
@@ -31,9 +33,9 @@ public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizeP
SynchronizePlexShowById request, SynchronizePlexShowById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Validation<BaseError, string> validation = await Validate(request, cancellationToken); Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match( return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken), parameters => PerformScan(parameters, request, cancellationToken),
error => error =>
{ {
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>()) foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -46,30 +48,44 @@ public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizeP
} }
private async Task<Either<BaseError, string>> PerformScan( private async Task<Either<BaseError, string>> PerformScan(
string scanner, ScanParameters parameters,
SynchronizePlexShowById request, SynchronizePlexShowById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var arguments = new List<string> Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.PlexLibraryId);
foreach (var scanId in maybeScanId)
{ {
"scan-plex-show", try
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture), {
request.ShowId.ToString(CultureInfo.InvariantCulture) var arguments = new List<string>
}; {
"scan-plex-show",
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture),
request.ShowId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.DeepScan) if (request.DeepScan)
{ {
arguments.Add("--deep"); arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
} }
return await base.PerformScan(scanner, arguments, cancellationToken); return BaseError.New($"Library {request.PlexLibraryId} is already scanning");
} }
protected override Task<DateTimeOffset> GetLastScan( protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext, TvContext dbContext,
SynchronizePlexShowById request, SynchronizePlexShowById request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
Task.FromResult(DateTimeOffset.MinValue); Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
protected override bool ScanIsRequired( protected override bool ScanIsRequired(
DateTimeOffset lastScan, DateTimeOffset lastScan,
@@ -2,5 +2,5 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex; namespace ErsatzTV.Application.Plex;
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>, public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan, bool DeepScan)
IScannerBackgroundServiceRequest; : IRequest<Either<BaseError, Unit>>, IScannerBackgroundServiceRequest;
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Resolutions;
public record GetResolutionByName(string Name) : IRequest<Option<ResolutionViewModel>>;
@@ -0,0 +1,18 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Resolutions;
public class GetResolutionByNameHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetResolutionByName, Option<ResolutionViewModel>>
{
public async Task<Option<ResolutionViewModel>> Handle(GetResolutionByName request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Resolutions
.AsNoTracking()
.SelectOneAsync(r => r.Name, r => r.Name == request.Name, cancellationToken)
.MapT(Mapper.ProjectToViewModel);
}
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Scheduling;
public record BlockTreeBlockGroupViewModel(int Id, string Name, List<BlockTreeBlockViewModel> Blocks);
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Scheduling;
public record BlockTreeBlockViewModel(int Id, string Name, int Minutes);
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Scheduling;
public record BlockTreeViewModel(List<BlockTreeBlockGroupViewModel> Groups);
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
public record CopyBlock(int BlockId, int NewBlockGroupId, string NewBlockName)
: IRequest<Either<BaseError, BlockViewModel>>;
@@ -0,0 +1,118 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Scheduling.Mapper;
namespace ErsatzTV.Application.Scheduling;
public class CopyBlockHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CopyBlock, Either<BaseError, BlockViewModel>>
{
public async Task<Either<BaseError, BlockViewModel>> Handle(
CopyBlock request,
CancellationToken cancellationToken)
{
try
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Block> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => PerformCopy(dbContext, p, request, cancellationToken));
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
return BaseError.New(ex.Message);
}
}
private static async Task<BlockViewModel> PerformCopy(
TvContext dbContext,
Block block,
CopyBlock request,
CancellationToken cancellationToken)
{
DetachEntity(dbContext, block);
block.Name = request.NewBlockName;
block.BlockGroup = null;
block.BlockGroupId = request.NewBlockGroupId;
foreach (BlockItem item in block.Items)
{
DetachEntity(dbContext, item);
item.BlockId = 0;
item.Block = block;
foreach (BlockItemWatermark watermark in item.BlockItemWatermarks)
{
DetachEntity(dbContext, watermark);
watermark.BlockItemId = 0;
watermark.BlockItem = item;
}
foreach (BlockItemGraphicsElement graphicsElement in item.BlockItemGraphicsElements)
{
DetachEntity(dbContext, graphicsElement);
graphicsElement.BlockItemId = 0;
graphicsElement.BlockItem = item;
}
}
await dbContext.Blocks.AddAsync(block, cancellationToken);
await dbContext.BlockItems.AddRangeAsync(block.Items, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.Entry(block).Reference(b => b.BlockGroup).LoadAsync(cancellationToken);
return ProjectToViewModel(block);
}
private static async Task<Validation<BaseError, Block>> Validate(
TvContext dbContext,
CopyBlock request,
CancellationToken cancellationToken) =>
(await BlockMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((block, _) => block);
private static Task<Validation<BaseError, Block>> BlockMustExist(
TvContext dbContext,
CopyBlock request,
CancellationToken cancellationToken) =>
dbContext.Blocks
.AsNoTracking()
.Include(ps => ps.Items)
.ThenInclude(i => i.BlockItemWatermarks)
.Include(ps => ps.Items)
.ThenInclude(i => i.BlockItemGraphicsElements)
.SelectOneAsync(p => p.Id, p => p.Id == request.BlockId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Block does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName(TvContext dbContext, CopyBlock request)
{
List<string> allNames = await dbContext.Blocks
.Where(b => b.BlockGroupId == request.NewBlockGroupId)
.Map(ps => ps.Name)
.ToListAsync();
Validation<BaseError, string> result1 = request.NotEmpty(c => c.NewBlockName)
.Bind(_ => request.NotLongerThan(50)(c => c.NewBlockName));
var result2 = Optional(request.NewBlockName)
.Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("Block name must be unique within the block group.");
return (result1, result2).Apply((_, _) => request.NewBlockName);
}
private static void DetachEntity<T>(TvContext db, T entity) where T : class
{
db.Entry(entity).State = EntityState.Detached;
if (entity.GetType().GetProperty("Id") is not null)
{
entity.GetType().GetProperty("Id")!.SetValue(entity, 0);
}
}
}
@@ -30,6 +30,7 @@ public class CreateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
{ {
DecoGroupId = request.DecoGroupId, DecoGroupId = request.DecoGroupId,
Name = name, Name = name,
BreakContent = [],
DecoWatermarks = [], DecoWatermarks = [],
DecoGraphicsElements = [] DecoGraphicsElements = []
}); });
@@ -10,4 +10,4 @@ public record ReplaceBlockItems(
int Minutes, int Minutes,
BlockStopScheduling StopScheduling, BlockStopScheduling StopScheduling,
List<ReplaceBlockItem> Items) List<ReplaceBlockItem> Items)
: IRequest<Either<BaseError, List<BlockItemViewModel>>>; : IRequest<Either<BaseError, Unit>>;
@@ -8,9 +8,9 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling; namespace ErsatzTV.Application.Scheduling;
public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFactory) public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ReplaceBlockItems, Either<BaseError, List<BlockItemViewModel>>> : IRequestHandler<ReplaceBlockItems, Either<BaseError, Unit>>
{ {
public async Task<Either<BaseError, List<BlockItemViewModel>>> Handle( public async Task<Either<BaseError, Unit>> Handle(
ReplaceBlockItems request, ReplaceBlockItems request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -19,7 +19,7 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken)); return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
} }
private static async Task<List<BlockItemViewModel>> Persist( private static async Task<Unit> Persist(
TvContext dbContext, TvContext dbContext,
ReplaceBlockItems request, ReplaceBlockItems request,
Block block, Block block,
@@ -41,7 +41,7 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh)); // await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
// } // }
return block.Items.Map(Mapper.ProjectToViewModel).ToList(); return Unit.Default;
} }
private static BlockItem BuildItem(Block block, int index, ReplaceBlockItem item) private static BlockItem BuildItem(Block block, int index, ReplaceBlockItem item)
@@ -28,15 +28,6 @@ internal static class Mapper
tg.Name, tg.Name,
tg.Templates.OrderBy(t => t.Name).Map(t => new TreeItemViewModel(t.Id, t.Name)).ToList())).ToList()); tg.Templates.OrderBy(t => t.Name).Map(t => new TreeItemViewModel(t.Id, t.Name)).ToList())).ToList());
internal static BlockTreeViewModel ProjectToViewModel(List<BlockGroup> blockGroups) =>
new(
blockGroups.OrderBy(bg => bg.Name).Map(bg => new BlockTreeBlockGroupViewModel(
bg.Id,
bg.Name,
bg.Blocks.OrderBy(b => b.Name).Map(b => new BlockTreeBlockViewModel(b.Id, b.Name, b.Minutes))
.ToList()))
.ToList());
internal static BlockGroupViewModel ProjectToViewModel(BlockGroup blockGroup) => internal static BlockGroupViewModel ProjectToViewModel(BlockGroup blockGroup) =>
new(blockGroup.Id, blockGroup.Name); new(blockGroup.Id, blockGroup.Name);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Scheduling;
public record GetAllBlocks : IRequest<List<BlockViewModel>>;
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Scheduling;
public record GetAllBlocksForPlayout(int PlayoutId) : IRequest<List<BlockViewModel>>;
@@ -0,0 +1,49 @@
using Dapper;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class GetAllBlocksForPlayoutHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllBlocksForPlayout, List<BlockViewModel>>
{
public async Task<List<BlockViewModel>> Handle(GetAllBlocksForPlayout request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<int> blockIds = await dbContext.Connection.QueryAsync<int>(
"""
SELECT DISTINCT TI.BlockId
FROM TemplateItem TI
INNER JOIN PlayoutTemplate PT ON PT.TemplateId = TI.TemplateId
WHERE PT.PlayoutId = @PlayoutId
""",
new { request.PlayoutId })
.Map(result => result.ToList());
List<Block> blocks = await dbContext.Blocks
.AsNoTracking()
.Where(b => blockIds.Contains(b.Id))
.ToListAsync(cancellationToken);
List<BlockGroup> blockGroups = await dbContext.BlockGroups
.AsNoTracking()
.ToListAsync(cancellationToken);
// match blocks to block groups
foreach (var block in blocks)
{
var maybeBlockGroup = blockGroups.FirstOrDefault(bg => bg.Id == block.BlockGroupId);
if (maybeBlockGroup != null)
{
block.BlockGroup = maybeBlockGroup;
}
}
return blocks.Map(Mapper.ProjectToViewModel)
.OrderBy(b => b.GroupName)
.ThenBy(b => b.Name)
.ToList();
}
}
@@ -0,0 +1,54 @@
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class GetAllBlocksHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllBlocks, List<BlockViewModel>>
{
public async Task<List<BlockViewModel>> Handle(GetAllBlocks request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Block> blocks = await dbContext.Blocks
.AsNoTracking()
.ToListAsync(cancellationToken);
List<BlockGroup> blockGroups = await dbContext.BlockGroups
.AsNoTracking()
.ToListAsync(cancellationToken);
var unusedBlockGroups = blockGroups.ToList();
// match blocks to block groups
foreach (var block in blocks)
{
var maybeBlockGroup = blockGroups.FirstOrDefault(bg => bg.Id == block.BlockGroupId);
if (maybeBlockGroup != null)
{
unusedBlockGroups.Remove(maybeBlockGroup);
block.BlockGroup = maybeBlockGroup;
}
}
// create dummy blocks for any groups that have no blocks yet
foreach (var unusedGroup in unusedBlockGroups)
{
var dummyBlock = new Block
{
Id = unusedGroup.Id * -1,
BlockGroupId = unusedGroup.Id,
BlockGroup = unusedGroup,
Name = "(none)"
};
blocks.Add(dummyBlock);
}
return blocks.Map(Mapper.ProjectToViewModel)
.OrderBy(b => b.GroupName)
.ThenBy(b => b.Name)
.ToList();
}
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Scheduling;
public record GetBlockTree : IRequest<BlockTreeViewModel>;
@@ -1,21 +0,0 @@
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class GetBlockTreeHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetBlockTree, BlockTreeViewModel>
{
public async Task<BlockTreeViewModel> Handle(GetBlockTree request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<BlockGroup> blockGroups = await dbContext.BlockGroups
.AsNoTracking()
.Include(g => g.Blocks)
.ToListAsync(cancellationToken);
return Mapper.ProjectToViewModel(blockGroups);
}
}
@@ -25,24 +25,24 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
private readonly IFFmpegSegmenterService _ffmpegSegmenterService; private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IGraphicsEngine _graphicsEngine; private readonly IGraphicsEngine _graphicsEngine;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter; private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly IHostApplicationLifetime _hostApplicationLifetime; private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<StartFFmpegSessionHandler> _logger; private readonly ILogger<StartFFmpegSessionHandler> _logger;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<HlsSessionWorker> _sessionWorkerLogger; private readonly ILogger<HlsSessionWorker> _sessionWorkerLogger;
private readonly ILogger<HlsSessionWorkerV2> _sessionWorkerV2Logger;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public StartFFmpegSessionHandler( public StartFFmpegSessionHandler(
IHlsPlaylistFilter hlsPlaylistFilter, IHlsPlaylistFilter hlsPlaylistFilter,
IHlsInitSegmentCache hlsInitSegmentCache,
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
IMediator mediator, IMediator mediator,
IClient client, IClient client,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<StartFFmpegSessionHandler> logger, ILogger<StartFFmpegSessionHandler> logger,
ILogger<HlsSessionWorker> sessionWorkerLogger, ILogger<HlsSessionWorker> sessionWorkerLogger,
ILogger<HlsSessionWorkerV2> sessionWorkerV2Logger,
IFFmpegSegmenterService ffmpegSegmenterService, IFFmpegSegmenterService ffmpegSegmenterService,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IGraphicsEngine graphicsEngine, IGraphicsEngine graphicsEngine,
@@ -50,13 +50,13 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
ChannelWriter<IBackgroundServiceRequest> workerChannel) ChannelWriter<IBackgroundServiceRequest> workerChannel)
{ {
_hlsPlaylistFilter = hlsPlaylistFilter; _hlsPlaylistFilter = hlsPlaylistFilter;
_hlsInitSegmentCache = hlsInitSegmentCache;
_serviceScopeFactory = serviceScopeFactory; _serviceScopeFactory = serviceScopeFactory;
_mediator = mediator; _mediator = mediator;
_client = client; _client = client;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_logger = logger; _logger = logger;
_sessionWorkerLogger = sessionWorkerLogger; _sessionWorkerLogger = sessionWorkerLogger;
_sessionWorkerV2Logger = sessionWorkerV2Logger;
_ffmpegSegmenterService = ffmpegSegmenterService; _ffmpegSegmenterService = ffmpegSegmenterService;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_graphicsEngine = graphicsEngine; _graphicsEngine = graphicsEngine;
@@ -92,7 +92,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
await _mediator.Send(new RefreshGraphicsElements(), cancellationToken); await _mediator.Send(new RefreshGraphicsElements(), cancellationToken);
IHlsSessionWorker worker = GetSessionWorker(request, targetFramerate); HlsSessionWorker worker = GetSessionWorker(request, targetFramerate);
_ffmpegSegmenterService.AddOrUpdateWorker(request.ChannelNumber, worker); _ffmpegSegmenterService.AddOrUpdateWorker(request.ChannelNumber, worker);
@@ -118,32 +118,16 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
return Unit.Default; return Unit.Default;
} }
private IHlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<int> targetFramerate) => private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<int> targetFramerate) =>
request.Mode switch request.Mode switch
{ {
"segmenter-v2" => new HlsSessionWorkerV2(
_serviceScopeFactory,
_localFileSystem,
_sessionWorkerV2Logger,
targetFramerate,
request.Scheme,
request.Host),
"segmenter-fmp4" => new HlsSessionWorker(
_serviceScopeFactory,
OutputFormatKind.HlsMp4,
_graphicsEngine,
_client,
_hlsPlaylistFilter,
_configElementRepository,
_localFileSystem,
_sessionWorkerLogger,
targetFramerate),
_ => new HlsSessionWorker( _ => new HlsSessionWorker(
_serviceScopeFactory, _serviceScopeFactory,
OutputFormatKind.Hls,
_graphicsEngine, _graphicsEngine,
_client, _client,
OutputFormatKind.Hls,
_hlsPlaylistFilter, _hlsPlaylistFilter,
_hlsInitSegmentCache,
_configElementRepository, _configElementRepository,
_localFileSystem, _localFileSystem,
_sessionWorkerLogger, _sessionWorkerLogger,
@@ -165,7 +149,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
request.ChannelNumber, request.ChannelNumber,
out IHlsSessionWorker worker)) out IHlsSessionWorker worker))
{ {
worker?.Touch(); worker?.Touch(Option<string>.None);
} }
return result.AsTask(); return result.AsTask();
@@ -26,8 +26,10 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
private static int _workAheadCount; private static int _workAheadCount;
private readonly IClient _client; private readonly IClient _client;
private readonly OutputFormatKind _outputFormatKind;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = [];
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly OutputFormatKind _outputFormat;
private readonly IGraphicsEngine _graphicsEngine; private readonly IGraphicsEngine _graphicsEngine;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter; private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
@@ -39,6 +41,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private CancellationTokenSource _cancellationTokenSource; private CancellationTokenSource _cancellationTokenSource;
private string _channelNumber; private string _channelNumber;
private DateTimeOffset _channelStart; private DateTimeOffset _channelStart;
private int _discontinuitySequence;
private bool _disposedValue; private bool _disposedValue;
private bool _hasWrittenSegments; private bool _hasWrittenSegments;
private DateTimeOffset _lastAccess; private DateTimeOffset _lastAccess;
@@ -47,13 +50,15 @@ public class HlsSessionWorker : IHlsSessionWorker
private HlsSessionState _state; private HlsSessionState _state;
private Timer _timer; private Timer _timer;
private DateTimeOffset _transcodedUntil; private DateTimeOffset _transcodedUntil;
private string _workingDirectory;
public HlsSessionWorker( public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
OutputFormatKind outputFormat,
IGraphicsEngine graphicsEngine, IGraphicsEngine graphicsEngine,
IClient client, IClient client,
OutputFormatKind outputFormatKind,
IHlsPlaylistFilter hlsPlaylistFilter, IHlsPlaylistFilter hlsPlaylistFilter,
IHlsInitSegmentCache hlsInitSegmentCache,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<HlsSessionWorker> logger, ILogger<HlsSessionWorker> logger,
@@ -61,9 +66,10 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
_serviceScope = serviceScopeFactory.CreateScope(); _serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>(); _mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
_outputFormat = outputFormat;
_graphicsEngine = graphicsEngine; _graphicsEngine = graphicsEngine;
_client = client; _client = client;
_outputFormatKind = outputFormatKind;
_hlsInitSegmentCache = hlsInitSegmentCache;
_hlsPlaylistFilter = hlsPlaylistFilter; _hlsPlaylistFilter = hlsPlaylistFilter;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
@@ -88,7 +94,7 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
} }
public void Touch() public void Touch(Option<string> fileName)
{ {
lock (_sync) lock (_sync)
{ {
@@ -114,12 +120,16 @@ public class HlsSessionWorker : IHlsSessionWorker
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken); Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
foreach (string[] input in maybeLines) foreach (string[] input in maybeLines)
{ {
await RefreshInits();
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(
_outputFormat, _discontinuityMap,
_outputFormatKind,
PlaylistStart, PlaylistStart,
filterBefore, filterBefore,
GetAllInits(), _hlsInitSegmentCache,
input); input,
maybeMaxSegments: 10);
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30)) if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
{ {
DeleteOldSegments(trimResult); DeleteOldSegments(trimResult);
@@ -171,6 +181,7 @@ public class HlsSessionWorker : IHlsSessionWorker
try try
{ {
_channelNumber = channelNumber; _channelNumber = channelNumber;
_workingDirectory = Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber);
foreach (TimeSpan timeout in idleTimeout) foreach (TimeSpan timeout in idleTimeout)
{ {
@@ -183,17 +194,14 @@ public class HlsSessionWorker : IHlsSessionWorker
CancellationToken cancellationToken = _cancellationTokenSource.Token; CancellationToken cancellationToken = _cancellationTokenSource.Token;
_logger.LogInformation( _logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
"Starting HLS session for channel {Channel} with output format {OutputFormat}",
channelNumber,
_outputFormat);
if (_localFileSystem.ListFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber)).Any()) if (_localFileSystem.ListFiles(_workingDirectory).Any())
{ {
_logger.LogError("Transcode folder is NOT empty!"); _logger.LogError("Transcode folder is NOT empty!");
} }
Touch(); Touch(Option<string>.None);
_transcodedUntil = DateTimeOffset.Now; _transcodedUntil = DateTimeOffset.Now;
PlaylistStart = _transcodedUntil; PlaylistStart = _transcodedUntil;
_channelStart = _transcodedUntil; _channelStart = _transcodedUntil;
@@ -261,7 +269,7 @@ public class HlsSessionWorker : IHlsSessionWorker
try try
{ {
_localFileSystem.EmptyFolder(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber)); _localFileSystem.EmptyFolder(_workingDirectory);
} }
catch catch
{ {
@@ -297,7 +305,7 @@ public class HlsSessionWorker : IHlsSessionWorker
DateTimeOffset start = DateTimeOffset.Now; DateTimeOffset start = DateTimeOffset.Now;
DateTimeOffset finish = start.AddSeconds(8); DateTimeOffset finish = start.AddSeconds(8);
string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber, "live.m3u8"); string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
_logger.LogDebug("Waiting for playlist to exist"); _logger.LogDebug("Waiting for playlist to exist");
while (!_localFileSystem.FileExists(playlistFileName)) while (!_localFileSystem.FileExists(playlistFileName))
@@ -366,14 +374,14 @@ public class HlsSessionWorker : IHlsSessionWorker
// playout updates should have the channel start over, transcode method will throttle if needed // playout updates should have the channel start over, transcode method will throttle if needed
HlsSessionState.PlayoutUpdated => HlsSessionState.SeekAndWorkAhead, HlsSessionState.PlayoutUpdated => HlsSessionState.SeekAndWorkAhead,
// after seeking and NOT completing the item, seek again, transcode method will throttle if needed // after seeking and NOT completing the item, seek again, transcode method will accelerate if needed
HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndWorkAhead, HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndRealtime,
// after seeking and completing the item, start at zero // after seeking and completing the item, start at zero
HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead, HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
// after starting and zero and NOT completing the item, seek, transcode method will throttle if needed // after starting and zero and NOT completing the item, seek, transcode method will accelerate if needed
HlsSessionState.ZeroAndWorkAhead when !isComplete => HlsSessionState.SeekAndWorkAhead, HlsSessionState.ZeroAndWorkAhead when !isComplete => HlsSessionState.SeekAndRealtime,
// after starting at zero and completing the item, start at zero again, transcode method will throttle if needed // after starting at zero and completing the item, start at zero again, transcode method will throttle if needed
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndWorkAhead, HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
@@ -393,12 +401,12 @@ public class HlsSessionWorker : IHlsSessionWorker
return result; return result;
} }
private async Task<bool> Transcode( private async Task<bool> Transcode(bool realtime, CancellationToken cancellationToken)
bool realtime,
CancellationToken cancellationToken)
{ {
try try
{ {
bool wasSeekAndWorkAhead = _state is HlsSessionState.SeekAndWorkAhead;
if (!realtime) if (!realtime)
{ {
Interlocked.Increment(ref _workAheadCount); Interlocked.Increment(ref _workAheadCount);
@@ -438,17 +446,16 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
} }
long ptsOffset = await GetPtsOffset(_channelNumber, cancellationToken); TimeSpan ptsOffset = await GetPtsOffset(_channelNumber, cancellationToken);
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
_logger.LogDebug("HLS session state: {State}", _state); _logger.LogDebug("HLS session state: {State}", _state);
DateTimeOffset now = _state is HlsSessionState.SeekAndWorkAhead ? DateTimeOffset.Now : _transcodedUntil; DateTimeOffset now = wasSeekAndWorkAhead ? DateTimeOffset.Now : _transcodedUntil;
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime; bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime;
var request = new GetPlayoutItemProcessByChannelNumber( var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber, _channelNumber,
_outputFormat is OutputFormatKind.HlsMp4 ? StreamingMode.HttpLiveStreamingSegmenterFmp4 : StreamingMode.HttpLiveStreamingSegmenter, StreamingMode.HttpLiveStreamingSegmenter,
now, now,
startAtZero, startAtZero,
realtime, realtime,
@@ -476,6 +483,14 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
await TrimAndDelete(cancellationToken); await TrimAndDelete(cancellationToken);
// increment discontinuity sequence and store with segment key (generated at)
foreach (long segmentKey in processModel.SegmentKey)
{
_discontinuitySequence++;
_discontinuityMap.TryAdd(segmentKey, _discontinuitySequence);
//_logger.LogDebug("DISCONTINUITY MAP {Map}", _discontinuityMap);
}
Option<Pipe> maybePipe = Option<Pipe>.None; Option<Pipe> maybePipe = Option<Pipe>.None;
var stdErrBuffer = new StringBuilder(); var stdErrBuffer = new StringBuilder();
@@ -502,6 +517,7 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
CommandResult commandResult = await processWithPipe CommandResult commandResult = await processWithPipe
.WithWorkingDirectory(_workingDirectory)
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer)) .WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
.WithValidation(CommandResultValidation.None) .WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token); .ExecuteAsync(linkedCts.Token);
@@ -509,7 +525,10 @@ public class HlsSessionWorker : IHlsSessionWorker
if (commandResult.ExitCode == 0) if (commandResult.ExitCode == 0)
{ {
_logger.LogDebug("HLS process has completed for channel {Channel}", _channelNumber); _logger.LogDebug("HLS process has completed for channel {Channel}", _channelNumber);
_logger.LogDebug("Transcoded until: {Until}", processModel.Until); _logger.LogDebug(
"Transcoded until: {Until} - Buffer: {Buffer} seconds",
processModel.Until,
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds);
_transcodedUntil = processModel.Until; _transcodedUntil = processModel.Until;
_state = NextState(_state, processModel); _state = NextState(_state, processModel);
_hasWrittenSegments = true; _hasWrittenSegments = true;
@@ -535,7 +554,7 @@ public class HlsSessionWorker : IHlsSessionWorker
Either<BaseError, PlayoutItemProcessModel> maybeOfflineProcess = await _mediator.Send( Either<BaseError, PlayoutItemProcessModel> maybeOfflineProcess = await _mediator.Send(
new GetErrorProcess( new GetErrorProcess(
_channelNumber, _channelNumber,
_outputFormat is OutputFormatKind.HlsMp4 ? StreamingMode.HttpLiveStreamingSegmenterFmp4 : StreamingMode.HttpLiveStreamingSegmenter, StreamingMode.HttpLiveStreamingSegmenter,
realtime, realtime,
ptsOffset, ptsOffset,
processModel.MaybeDuration, processModel.MaybeDuration,
@@ -630,12 +649,15 @@ public class HlsSessionWorker : IHlsSessionWorker
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken); Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
foreach (string[] lines in maybeLines) foreach (string[] lines in maybeLines)
{ {
await RefreshInits();
// trim playlist and insert discontinuity before appending with new ffmpeg process // trim playlist and insert discontinuity before appending with new ffmpeg process
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
_outputFormat, _discontinuityMap,
_outputFormatKind,
PlaylistStart, PlaylistStart,
DateTimeOffset.Now.AddMinutes(-1), DateTimeOffset.Now.AddMinutes(-1),
GetAllInits(), _hlsInitSegmentCache,
lines); lines);
await WritePlaylist(trimResult.Playlist, cancellationToken); await WritePlaylist(trimResult.Playlist, cancellationToken);
@@ -652,10 +674,12 @@ public class HlsSessionWorker : IHlsSessionWorker
private void DeleteOldSegments(TrimPlaylistResult trimResult) private void DeleteOldSegments(TrimPlaylistResult trimResult)
{ {
var generatedAtHash = new System.Collections.Generic.HashSet<long>();
// delete old segments // delete old segments
var allSegments = Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "live*.ts") var allSegments = Directory.GetFiles(_workingDirectory, "live*.ts")
.Append(Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "live*.mp4")) .Append(Directory.GetFiles(_workingDirectory, "live*.mp4"))
.Append(Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "live*.m4s")) .Append(Directory.GetFiles(_workingDirectory, "live*.m4s"))
.Map(file => .Map(file =>
{ {
string fileName = Path.GetFileName(file); string fileName = Path.GetFileName(file);
@@ -668,18 +692,15 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
generatedAt = 0; generatedAt = 0;
} }
generatedAtHash.Add(generatedAt);
return new Segment(file, sequenceNumber, generatedAt); return new Segment(file, sequenceNumber, generatedAt);
}) })
.ToList(); .ToList();
var allInits = Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "*init.mp4") var allInits = Directory.GetFiles(_workingDirectory, "*init.mp4")
.Map(file => .Map(file => long.TryParse(Path.GetFileName(file).Split('_')[0], out long generatedAt) && !generatedAtHash.Contains(generatedAt)
{ ? new Segment(file, 0, generatedAt)
string fileName = Path.GetFileName(file); : Option<Segment>.None)
return long.TryParse(fileName.Split('_')[0], out long generatedAt)
? new Segment(file, 0, generatedAt)
: Option<Segment>.None;
})
.Somes() .Somes()
.ToList(); .ToList();
@@ -693,16 +714,23 @@ public class HlsSessionWorker : IHlsSessionWorker
// trimResult.Sequence); // trimResult.Sequence);
} }
if (allInits.Count > 0 && allSegments.Count > 0) foreach (var init in allInits)
{ {
long minKeep = allSegments.Except(toDelete).Map(s => s.GeneratedAt).Min(); // only consider deleting inits that have no segments left on disk, no segments in ffmpeg playlist
Option<Segment> maybeMinKeepInit = if (generatedAtHash.Contains(init.GeneratedAt) || init.GeneratedAt >= trimResult.GeneratedAt)
allInits.OrderByDescending(i => i.GeneratedAt).Find(i => i.GeneratedAt <= minKeep);
foreach (var minKeepInit in maybeMinKeepInit)
{ {
// _logger.LogDebug("Deleting HLS inits less than {GeneratedAt}", minKeepInit.GeneratedAt); continue;
toDelete.AddRange(allInits.Where(i => i.GeneratedAt < minKeepInit.GeneratedAt));
} }
string fileName = Path.GetFileName(init.File);
if (_hlsInitSegmentCache.IsEarliestByHash(fileName))
{
continue;
}
toDelete.Add(init);
_hlsInitSegmentCache.DeleteSegment(fileName);
_discontinuityMap.Remove(init.GeneratedAt);
} }
foreach (Segment segment in toDelete) foreach (Segment segment in toDelete)
@@ -720,23 +748,29 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
} }
private List<long> GetAllInits() => private async Task RefreshInits()
_outputFormat is OutputFormatKind.HlsMp4 {
? Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "*init.mp4") var allSegments = Directory.GetFiles(_workingDirectory, "live*.m4s")
.Map(file => .Map(Path.GetFileName)
{ .Map(s => s.Split("_")[1])
string fileName = Path.GetFileName(file); .ToHashSet();
return long.TryParse(fileName.Split('_')[0], out long generatedAt) ? generatedAt : long.MaxValue;
})
.ToList()
: [];
private async Task<long> GetPtsOffset(string channelNumber, CancellationToken cancellationToken) foreach (string file in Directory.GetFiles(_workingDirectory, "*init.mp4"))
{
string key = Path.GetFileName(file).Split("_")[0];
if (allSegments.Contains(key))
{
await _hlsInitSegmentCache.AddSegment(file);
}
}
}
private async Task<TimeSpan> GetPtsOffset(string channelNumber, CancellationToken cancellationToken)
{ {
await _slim.WaitAsync(cancellationToken); await _slim.WaitAsync(cancellationToken);
try try
{ {
long result = 0; TimeSpan result = TimeSpan.Zero;
// if we haven't yet written any segments, start at zero // if we haven't yet written any segments, start at zero
if (!_hasWrittenSegments) if (!_hasWrittenSegments)
@@ -744,8 +778,10 @@ public class HlsSessionWorker : IHlsSessionWorker
return result; return result;
} }
await RefreshInits();
Either<BaseError, PtsTime> queryResult = await _mediator.Send( Either<BaseError, PtsTime> queryResult = await _mediator.Send(
new GetLastPtsTime(channelNumber), new GetLastPtsTime(_hlsInitSegmentCache, channelNumber),
cancellationToken); cancellationToken);
foreach (BaseError error in queryResult.LeftToSeq()) foreach (BaseError error in queryResult.LeftToSeq())
@@ -755,6 +791,7 @@ public class HlsSessionWorker : IHlsSessionWorker
foreach (PtsTime pts in queryResult.RightToSeq()) foreach (PtsTime pts in queryResult.RightToSeq())
{ {
_logger.LogWarning("Last pts offset is {Pts}", pts.Value);
result = pts.Value; result = pts.Value;
} }
@@ -788,10 +825,7 @@ public class HlsSessionWorker : IHlsSessionWorker
await File.WriteAllTextAsync(fileName, playlist, cancellationToken); await File.WriteAllTextAsync(fileName, playlist, cancellationToken);
} }
private string PlaylistFileName() => Path.Combine( private string PlaylistFileName() => Path.Combine(_workingDirectory, "live.m3u8");
FileSystemLayout.TranscodeFolder,
_channelNumber,
"live.m3u8");
private sealed record Segment(string File, long SequenceNumber, long GeneratedAt); private sealed record Segment(string File, long SequenceNumber, long GeneratedAt);
} }
@@ -1,400 +0,0 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Timers;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace ErsatzTV.Application.Streaming;
public class HlsSessionWorkerV2 : IHlsSessionWorker
{
//private static int _workAheadCount;
private readonly string _host;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<HlsSessionWorkerV2> _logger;
private readonly IMediator _mediator;
private readonly string _scheme;
private readonly SemaphoreSlim _slim = new(1, 1);
private readonly Lock _sync = new();
private readonly Option<int> _targetFramerate;
private CancellationTokenSource _cancellationTokenSource;
private string _channelNumber;
private DateTimeOffset _channelStart;
private bool _disposedValue;
private DateTimeOffset _lastAccess;
private Option<PlayoutItemProcessModel> _lastProcessModel;
private IServiceScope _serviceScope;
private HlsSessionState _state;
private Timer _timer;
private DateTimeOffset _transcodedUntil;
public HlsSessionWorkerV2(
IServiceScopeFactory serviceScopeFactory,
ILocalFileSystem localFileSystem,
ILogger<HlsSessionWorkerV2> logger,
Option<int> targetFramerate,
string scheme,
string host)
{
_serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
_localFileSystem = localFileSystem;
_logger = logger;
_targetFramerate = targetFramerate;
_scheme = scheme;
_host = host;
}
public DateTimeOffset PlaylistStart { get; private set; }
public async Task Cancel(CancellationToken cancellationToken)
{
_logger.LogInformation("API termination request for HLS session for channel {Channel}", _channelNumber);
await _slim.WaitAsync(cancellationToken);
try
{
await _cancellationTokenSource.CancelAsync();
}
finally
{
_slim.Release();
}
}
public void Touch()
{
lock (_sync)
{
//_logger.LogDebug("Keep alive - session worker v2 for channel {ChannelNumber}", _channelNumber);
_lastAccess = DateTimeOffset.Now;
_timer?.Stop();
_timer?.Start();
}
}
public Task<Option<TrimPlaylistResult>> TrimPlaylist(
DateTimeOffset filterBefore,
CancellationToken cancellationToken) =>
Task.FromResult(Option<TrimPlaylistResult>.None);
public void PlayoutUpdated() => _state = HlsSessionState.PlayoutUpdated;
public HlsSessionModel GetModel() => new(_channelNumber, _state.ToString(), _transcodedUntil, _lastAccess);
void IDisposable.Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
public async Task Run(
string channelNumber,
Option<TimeSpan> idleTimeout,
CancellationToken incomingCancellationToken)
{
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken);
try
{
_channelNumber = channelNumber;
foreach (TimeSpan timeout in idleTimeout)
{
lock (_sync)
{
_timer = new Timer(timeout.TotalMilliseconds) { AutoReset = false };
_timer.Elapsed += CancelRun;
}
}
CancellationToken cancellationToken = _cancellationTokenSource.Token;
_logger.LogInformation("Starting HLS V2 session for channel {Channel}", channelNumber);
if (_localFileSystem.ListFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber)).Any())
{
_logger.LogError("Transcode folder is NOT empty!");
}
Touch();
_transcodedUntil = DateTimeOffset.Now;
PlaylistStart = _transcodedUntil;
_channelStart = _transcodedUntil;
Option<int> maybePlayoutId = await _mediator.Send(
new GetPlayoutIdByChannelNumber(_channelNumber),
cancellationToken);
// time shift on-demand playout if needed
foreach (int playoutId in maybePlayoutId)
{
await _mediator.Send(
new TimeShiftOnDemandPlayout(playoutId, _transcodedUntil, true),
cancellationToken);
}
// start concat/segmenter process
// other transcode processes will be started by incoming requests from concat/segmenter process
var request = new GetConcatSegmenterProcessByChannelNumber(_scheme, _host, _channelNumber);
Either<BaseError, PlayoutItemProcessModel> maybeSegmenterProcess =
await _mediator.Send(request, cancellationToken);
foreach (BaseError error in maybeSegmenterProcess.LeftToSeq())
{
_logger.LogError(
"Failed to start concat segmenter for channel {ChannelNumber}: {Error}",
_channelNumber,
error.ToString());
return;
}
foreach (PlayoutItemProcessModel processModel in maybeSegmenterProcess.RightAsEnumerable())
{
Command process = processModel.Process;
_logger.LogDebug("ffmpeg concat segmenter arguments {FFmpegArguments}", process.Arguments);
try
{
BufferedCommandResult commandResult = await process
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// TODO: handle result? this will probably *always* be canceled
_logger.LogDebug("ffmpeg concat segmenter finished (canceled)");
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unexpected error in HLS Session Worker V2");
}
finally
{
if (_timer is not null)
{
lock (_sync)
{
_timer.Elapsed -= CancelRun;
}
}
try
{
await _mediator.Send(
new UpdateOnDemandCheckpoint(_channelNumber, DateTimeOffset.Now),
CancellationToken.None);
}
catch (Exception)
{
// do nothing
}
try
{
_localFileSystem.EmptyFolder(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber));
}
catch
{
// do nothing
}
}
return;
[SuppressMessage("Usage", "VSTHRD100:Avoid async void methods")]
async void CancelRun(object o, ElapsedEventArgs e)
{
try
{
await _cancellationTokenSource.CancelAsync();
}
catch (Exception)
{
// do nothing
}
}
}
public async Task WaitForPlaylistSegments(
int initialSegmentCount,
CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
try
{
DateTimeOffset start = DateTimeOffset.Now;
DateTimeOffset finish = start.AddSeconds(8);
string segmentFolder = Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber);
string playlistFileName = Path.Combine(segmentFolder, "live.m3u8");
_logger.LogDebug("Waiting for playlist to exist");
while (!_localFileSystem.FileExists(playlistFileName))
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
_logger.LogDebug("Playlist exists");
var segmentCount = 0;
int lastSegmentCount = -1;
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
{
if (segmentCount != lastSegmentCount)
{
lastSegmentCount = segmentCount;
_logger.LogDebug(
"Segment count {SegmentCount} of {InitialSegmentCount}",
segmentCount,
initialSegmentCount);
}
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
segmentCount = _localFileSystem.ListFiles(segmentFolder, "*.ts").Count();
}
}
finally
{
sw.Stop();
_logger.LogDebug("WaitForPlaylistSegments took {Duration}", sw.Elapsed);
}
}
public async Task<Either<BaseError, PlayoutItemProcessModel>> GetNextPlayoutItemProcess()
{
foreach (PlayoutItemProcessModel processModel in _lastProcessModel)
{
_state = NextState(_state, processModel);
}
// if we're at least 30 seconds ahead, drop to realtime
var transcodedBuffer = TimeSpan.FromSeconds(
Math.Max(0, _transcodedUntil.Subtract(DateTimeOffset.Now).TotalSeconds));
if (transcodedBuffer >= TimeSpan.FromSeconds(30))
{
// throttle to realtime if needed
HlsSessionState nextState = _state switch
{
HlsSessionState.SeekAndWorkAhead => HlsSessionState.SeekAndRealtime,
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndRealtime,
_ => _state
};
if (nextState != _state)
{
_logger.LogDebug("HLS session state throttling {Last} => {Next}", _state, nextState);
_state = nextState;
}
}
_logger.LogDebug("Getting next playout item process with state {@State}", _state);
//long ptsOffset = await GetPtsOffset(_channelNumber, CancellationToken.None);
bool startAtZero = _state is HlsSessionState.ZeroAndRealtime or HlsSessionState.ZeroAndWorkAhead;
bool realtime = _state is HlsSessionState.ZeroAndRealtime or HlsSessionState.SeekAndRealtime;
var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenterV2,
_transcodedUntil,
startAtZero,
realtime,
_channelStart,
0,
_targetFramerate);
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
foreach (PlayoutItemProcessModel processModel in result.RightToSeq())
{
_logger.LogDebug("Next playout item process will transcode until {Until}", processModel.Until);
_transcodedUntil = processModel.Until;
_lastProcessModel = processModel;
}
if (result.IsLeft)
{
_lastProcessModel = Option<PlayoutItemProcessModel>.None;
}
return result;
}
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
if (_timer is not null)
{
_timer.Dispose();
_timer = null;
}
_serviceScope.Dispose();
_serviceScope = null;
}
_disposedValue = true;
}
}
private HlsSessionState NextState(HlsSessionState state, PlayoutItemProcessModel processModel)
{
bool isComplete = processModel?.IsComplete == true;
HlsSessionState result = state switch
{
// playout updates should have the channel start over, transcode method will throttle if needed
HlsSessionState.PlayoutUpdated => HlsSessionState.SeekAndWorkAhead,
// after seeking and NOT completing the item, seek again, transcode method will throttle if needed
HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndWorkAhead,
// after seeking and completing the item, start at zero
HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
// after starting and zero and NOT completing the item, seek, transcode method will throttle if needed
HlsSessionState.ZeroAndWorkAhead when !isComplete => HlsSessionState.SeekAndWorkAhead,
// after starting at zero and completing the item, start at zero again, transcode method will throttle if needed
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
// realtime will always complete items, so start next at zero
HlsSessionState.SeekAndRealtime => HlsSessionState.ZeroAndRealtime,
// realtime will always complete items, so start next at zero
HlsSessionState.ZeroAndRealtime => HlsSessionState.ZeroAndRealtime,
// this will never happen with the enum
_ => throw new InvalidOperationException()
};
_logger.LogDebug("HLS session state {Last} => {Next}", state, result);
return result;
}
}
@@ -8,4 +8,5 @@ public record PlayoutItemProcessModel(
Option<GraphicsEngineContext> GraphicsEngineContext, Option<GraphicsEngineContext> GraphicsEngineContext,
Option<TimeSpan> MaybeDuration, Option<TimeSpan> MaybeDuration,
DateTimeOffset Until, DateTimeOffset Until,
bool IsComplete); bool IsComplete,
Option<long> SegmentKey);
+5 -5
View File
@@ -2,19 +2,19 @@
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public record PtsTime(long Value) public record PtsTime(TimeSpan Value)
{ {
public static readonly PtsTime Zero = new(0); public static readonly PtsTime Zero = new(TimeSpan.Zero);
public static PtsTime From(string ffprobeLine) public static PtsTime From(string ffprobeLine)
{ {
string[] split = ffprobeLine.Split("|"); string[] split = ffprobeLine.Split("|");
var ptsTime = long.Parse(split[0], CultureInfo.InvariantCulture); var ptsTime = double.Parse(split[0], CultureInfo.InvariantCulture);
if (long.TryParse(split[1], CultureInfo.InvariantCulture, out long duration)) if (double.TryParse(split[1], CultureInfo.InvariantCulture, out double duration))
{ {
ptsTime += duration; ptsTime += duration;
} }
return new PtsTime(ptsTime); return new PtsTime(TimeSpan.FromSeconds(ptsTime));
} }
} }
@@ -10,4 +10,4 @@ public record FFmpegProcessRequest(
bool StartAtZero, bool StartAtZero,
bool HlsRealtime, bool HlsRealtime,
DateTimeOffset ChannelStartTime, DateTimeOffset ChannelStartTime,
long PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>; TimeSpan PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
@@ -11,7 +11,7 @@ public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
false, false,
true, true,
DateTimeOffset.Now, // unused DateTimeOffset.Now, // unused
0) TimeSpan.Zero)
{ {
Scheme = scheme; Scheme = scheme;
Host = host; Host = host;
@@ -43,6 +43,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
Option<TimeSpan>.None, Option<TimeSpan>.None,
DateTimeOffset.MaxValue, DateTimeOffset.MaxValue,
true); true,
Option<long>.None);
} }
} }
@@ -1,22 +0,0 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Streaming;
public record GetConcatSegmenterProcessByChannelNumber : FFmpegProcessRequest
{
public GetConcatSegmenterProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
StreamingMode.TransportStream,
DateTimeOffset.Now,
false,
true,
DateTimeOffset.Now, // unused
0)
{
Scheme = scheme;
Host = host;
}
public string Scheme { get; }
public string Host { get; }
}
@@ -1,43 +0,0 @@
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming;
public class GetConcatSegmenterProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: FFmpegProcessHandler<GetConcatSegmenterProcessByChannelNumber>(dbContextFactory)
{
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetConcatSegmenterProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken)
.Map(result => result.IfNone(false));
Command process = await ffmpegProcessService.ConcatSegmenterChannel(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(
process,
Option<GraphicsEngineContext>.None,
Option<TimeSpan>.None,
DateTimeOffset.MaxValue,
true);
}
}
@@ -6,7 +6,7 @@ public record GetErrorProcess(
string ChannelNumber, string ChannelNumber,
StreamingMode Mode, StreamingMode Mode,
bool HlsRealtime, bool HlsRealtime,
long PtsOffset, TimeSpan PtsOffset,
Option<TimeSpan> MaybeDuration, Option<TimeSpan> MaybeDuration,
DateTimeOffset Until, DateTimeOffset Until,
string ErrorMessage) : FFmpegProcessRequest( string ErrorMessage) : FFmpegProcessRequest(
@@ -8,16 +8,11 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess> public class GetErrorProcessHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: FFmpegProcessHandler<GetErrorProcess>(dbContextFactory)
{ {
private readonly IFFmpegProcessService _ffmpegProcessService;
public GetErrorProcessHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: base(dbContextFactory) =>
_ffmpegProcessService = ffmpegProcessService;
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess( protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext, TvContext dbContext,
GetErrorProcess request, GetErrorProcess request,
@@ -26,9 +21,12 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
string ffprobePath, string ffprobePath,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Command process = await _ffmpegProcessService.ForError( DateTimeOffset now = DateTimeOffset.Now;
Command process = await ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now,
request.MaybeDuration, request.MaybeDuration,
request.ErrorMessage, request.ErrorMessage,
request.HlsRealtime, request.HlsRealtime,
@@ -43,6 +41,7 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
request.MaybeDuration, request.MaybeDuration,
request.Until, request.Until,
true); true,
now.ToUnixTimeSeconds());
} }
} }
@@ -37,9 +37,18 @@ public class GetHlsPlaylistByChannelNumberHandler :
Parameters parameters, Parameters parameters,
DateTimeOffset now) DateTimeOffset now)
{ {
string mode = string.IsNullOrWhiteSpace(request.Mode) string mode = request.Mode switch
? string.Empty {
: $"&mode={request.Mode}"; "segmenter" or "ts-legacy" or "ts" => $"&mode={request.Mode}",
// "hls-direct" => string.Empty,
_ => string.Empty
};
string endpoint = request.Mode switch
{
"hls-direct" => "iptv/hls-direct",
_ => "ffmpeg/stream"
};
long index = GetIndexForChannel(parameters.Channel, parameters.PlayoutItem); long index = GetIndexForChannel(parameters.Channel, parameters.PlayoutItem);
double timeRemaining = Math.Abs((parameters.PlayoutItem.FinishOffset - now).TotalSeconds); double timeRemaining = Math.Abs((parameters.PlayoutItem.FinishOffset - now).TotalSeconds);
@@ -49,7 +58,7 @@ public class GetHlsPlaylistByChannelNumberHandler :
#EXT-X-MEDIA-SEQUENCE:{index} #EXT-X-MEDIA-SEQUENCE:{index}
#EXT-X-DISCONTINUITY #EXT-X-DISCONTINUITY
#EXTINF:{timeRemaining:F2}, #EXTINF:{timeRemaining:F2},
{request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber}?index={index}{mode} {request.Scheme}://{request.Host}/{endpoint}/{request.ChannelNumber}?index={index}{mode}
".AsTask(); ".AsTask();
} }
@@ -1,5 +1,7 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public record GetLastPtsTime(string ChannelNumber) : IRequest<Either<BaseError, PtsTime>>; public record GetLastPtsTime(IHlsInitSegmentCache InitSegmentCache, string ChannelNumber)
: IRequest<Either<BaseError, PtsTime>>;
@@ -12,28 +12,14 @@ using Newtonsoft.Json;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<BaseError, PtsTime>> public class GetLastPtsTimeHandler(
IClient client,
ILocalFileSystem localFileSystem,
ITempFilePool tempFilePool,
IConfigElementRepository configElementRepository,
ILogger<GetLastPtsTimeHandler> logger)
: IRequestHandler<GetLastPtsTime, Either<BaseError, PtsTime>>
{ {
private readonly IClient _client;
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetLastPtsTimeHandler> _logger;
private readonly ITempFilePool _tempFilePool;
public GetLastPtsTimeHandler(
IClient client,
ILocalFileSystem localFileSystem,
ITempFilePool tempFilePool,
IConfigElementRepository configElementRepository,
ILogger<GetLastPtsTimeHandler> logger)
{
_client = client;
_localFileSystem = localFileSystem;
_tempFilePool = tempFilePool;
_configElementRepository = configElementRepository;
_logger = logger;
}
public async Task<Either<BaseError, PtsTime>> Handle( public async Task<Either<BaseError, PtsTime>> Handle(
GetLastPtsTime request, GetLastPtsTime request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -48,13 +34,13 @@ public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<Base
GetLastPtsTime request, GetLastPtsTime request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await ValidateFFprobePath(cancellationToken) await ValidateFFprobePath(cancellationToken)
.MapT(ffprobePath => new RequestParameters(request.ChannelNumber, ffprobePath)); .MapT(ffprobePath => new RequestParameters(request.InitSegmentCache, request.ChannelNumber, ffprobePath));
private async Task<Either<BaseError, PtsTime>> Handle( private async Task<Either<BaseError, PtsTime>> Handle(
RequestParameters parameters, RequestParameters parameters,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Option<FileInfo> maybeLastSegment = GetLastSegment(parameters.ChannelNumber); Option<FileInfo> maybeLastSegment = GetLastSegment(parameters);
foreach (FileInfo segment in maybeLastSegment) foreach (FileInfo segment in maybeLastSegment)
{ {
return await GetPts(parameters, segment, cancellationToken).IfNoneAsync(PtsTime.Zero); return await GetPts(parameters, segment, cancellationToken).IfNoneAsync(PtsTime.Zero);
@@ -72,20 +58,33 @@ public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<Base
{ {
"-v", "0", "-v", "0",
"-show_entries", "-show_entries",
"packet=pts,duration", "packet=pts_time,duration_time",
"-of", "compact=p=0:nk=1", "-of", "compact=p=0:nk=1",
segment.FullName segment.FullName
}; };
string lastLine = string.Empty; PtsTime maxTime = PtsTime.Zero;
Action<string> replaceLine = s => Action<string> replaceLine = s =>
{ {
if (!string.IsNullOrWhiteSpace(s)) if (!string.IsNullOrWhiteSpace(s))
{ {
lastLine = s.Trim(); try
{
var newPts = PtsTime.From(s.Trim());
if (newPts.Value > maxTime.Value)
{
maxTime = newPts;
}
}
catch
{
// do nothing
}
} }
}; };
logger.LogDebug("ffprobe arguments {FFmpegArguments}", argumentList.ToList());
CommandResult probe = await Cli.Wrap(parameters.FFprobePath) CommandResult probe = await Cli.Wrap(parameters.FFprobePath)
.WithArguments(argumentList) .WithArguments(argumentList)
.WithValidation(CommandResultValidation.None) .WithValidation(CommandResultValidation.None)
@@ -97,27 +96,66 @@ public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<Base
return Option<PtsTime>.None; return Option<PtsTime>.None;
} }
try return maxTime;
{
return PtsTime.From(lastLine);
}
catch (Exception ex)
{
_client.Notify(ex);
await SaveTroubleshootingData(parameters.ChannelNumber, lastLine);
}
return Option<PtsTime>.None;
} }
private static Option<FileInfo> GetLastSegment(string channelNumber) private Option<FileInfo> GetLastSegment(RequestParameters parameters)
{ {
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber)); var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, parameters.ChannelNumber));
return Optional(directory.GetFiles("*.ts").OrderByDescending(f => f.Name).FirstOrDefault()); var allFiles = directory.GetFiles("*.ts").Append(directory.GetFiles("*.m4s")).ToList();
Option<FileInfo> maybeLastSegment = Optional(allFiles.OrderByDescending(f => f.Name).FirstOrDefault());
foreach (var lastSegment in maybeLastSegment)
{
if (lastSegment.Name.Contains("m4s"))
{
string[] split = lastSegment.Name.Split('_');
if (long.TryParse(split[1], out long generatedAt))
{
try
{
string init = parameters.InitSegmentCache.EarliestSegmentByHash(generatedAt);
string fullInit = Path.Combine(directory.FullName, init);
string combined = tempFilePool.GetNextTempFile(TempFileCategory.Fmp4LastSegment);
using (var output = File.OpenWrite(combined))
{
// copy init
using (var readInit = File.OpenRead(fullInit))
{
readInit.CopyTo(output);
}
// copy segment
using (var readSegment = File.OpenRead(lastSegment.FullName))
{
readSegment.CopyTo(output);
}
}
// return concatenated init + segment
return new FileInfo(combined);
}
catch (Exception e)
{
Console.WriteLine(e);
Console.WriteLine($"Can't find init for last segment {lastSegment.FullName}");
foreach (var file in allFiles)
{
Console.WriteLine(file.FullName);
}
throw;
}
}
}
return lastSegment;
}
return Option<FileInfo>.None;
} }
private Task<Validation<BaseError, string>> ValidateFFprobePath(CancellationToken cancellationToken) => private Task<Validation<BaseError, string>> ValidateFFprobePath(CancellationToken cancellationToken) =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken) configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
.FilterT(File.Exists) .FilterT(File.Exists)
.Map(ffprobePath => ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system")); .Map(ffprobePath => ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
@@ -130,7 +168,7 @@ public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<Base
string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8"); string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
string playlistContents = string.Empty; string playlistContents = string.Empty;
if (_localFileSystem.FileExists(playlistFileName)) if (localFileSystem.FileExists(playlistFileName))
{ {
playlistContents = await File.ReadAllTextAsync(playlistFileName); playlistContents = await File.ReadAllTextAsync(playlistFileName);
} }
@@ -138,18 +176,21 @@ public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<Base
var data = new TroubleshootingData(allFiles, playlistContents, output); var data = new TroubleshootingData(allFiles, playlistContents, output);
string serialized = data.Serialize(); string serialized = data.Serialize();
string file = _tempFilePool.GetNextTempFile(TempFileCategory.BadTranscodeFolder); string file = tempFilePool.GetNextTempFile(TempFileCategory.BadTranscodeFolder);
await File.WriteAllTextAsync(file, serialized); await File.WriteAllTextAsync(file, serialized);
_logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file); logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file);
} }
catch (Exception ex) catch (Exception ex)
{ {
_client.Notify(ex); client.Notify(ex);
} }
} }
private sealed record RequestParameters(string ChannelNumber, string FFprobePath); private sealed record RequestParameters(
IHlsInitSegmentCache InitSegmentCache,
string ChannelNumber,
string FFprobePath);
private sealed record TroubleshootingData(IEnumerable<FileInfo> Files, string Playlist, string ProbeOutput) private sealed record TroubleshootingData(IEnumerable<FileInfo> Files, string Playlist, string ProbeOutput)
{ {
@@ -9,7 +9,7 @@ public record GetPlayoutItemProcessByChannelNumber(
bool StartAtZero, bool StartAtZero,
bool HlsRealtime, bool HlsRealtime,
DateTimeOffset ChannelStart, DateTimeOffset ChannelStart,
long PtsOffset, TimeSpan PtsOffset,
Option<int> TargetFramerate) : FFmpegProcessRequest( Option<int> TargetFramerate) : FFmpegProcessRequest(
ChannelNumber, ChannelNumber,
Mode, Mode,
@@ -281,11 +281,28 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
DateTimeOffset effectiveNow = request.StartAtZero ? start : now; DateTimeOffset effectiveNow = request.StartAtZero ? start : now;
TimeSpan duration = finish - effectiveNow; TimeSpan duration = finish - effectiveNow;
bool isComplete = true;
// if we are working ahead, limit to 44s (multiple of segment size)
if (!request.HlsRealtime)
{
TimeSpan limit = TimeSpan.FromSeconds(44);
if (duration > limit)
{
finish = effectiveNow + limit;
outPoint = inPoint + limit;
duration = limit;
isComplete = false;
}
}
if (_isDebugNoSync) if (_isDebugNoSync)
{ {
Command doesNotExistProcess = await _ffmpegProcessService.ForError( Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now,
duration, duration,
$"DEBUG_NO_SYNC:\n{Mapper.GetDisplayTitle(playoutItemWithPath.PlayoutItem.MediaItem, Option<string>.None)}\nFrom: {start} To: {finish}", $"DEBUG_NO_SYNC:\n{Mapper.GetDisplayTitle(playoutItemWithPath.PlayoutItem.MediaItem, Option<string>.None)}\nFrom: {start} To: {finish}",
request.HlsRealtime, request.HlsRealtime,
@@ -300,7 +317,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
duration, duration,
finish, finish,
true); true,
now.ToUnixTimeSeconds());
} }
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion(); MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
@@ -414,12 +432,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
: StreamInputKind.Vod, : StreamInputKind.Vod,
playoutItemWithPath.PlayoutItem.FillerKind, playoutItemWithPath.PlayoutItem.FillerKind,
inPoint, inPoint,
outPoint,
request.ChannelStartTime, request.ChannelStartTime,
request.PtsOffset, request.PtsOffset,
request.TargetFramerate, request.TargetFramerate,
Option<string>.None, Option<string>.None,
_ => { }, _ => { },
canProxy: true,
cancellationToken); cancellationToken);
var result = new PlayoutItemProcessModel( var result = new PlayoutItemProcessModel(
@@ -427,7 +445,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
playoutItemResult.GraphicsEngineContext, playoutItemResult.GraphicsEngineContext,
duration, duration,
finish, finish,
true); isComplete,
effectiveNow.ToUnixTimeSeconds());
return Right<BaseError, PlayoutItemProcessModel>(result); return Right<BaseError, PlayoutItemProcessModel>(result);
} }
@@ -458,6 +477,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Command offlineProcess = await _ffmpegProcessService.ForError( Command offlineProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now,
maybeDuration, maybeDuration,
"Channel is Offline", "Channel is Offline",
request.HlsRealtime, request.HlsRealtime,
@@ -472,11 +492,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
maybeDuration, maybeDuration,
finish, finish,
true); true,
now.ToUnixTimeSeconds());
case PlayoutItemDoesNotExistOnDisk: case PlayoutItemDoesNotExistOnDisk:
Command doesNotExistProcess = await _ffmpegProcessService.ForError( Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now,
maybeDuration, maybeDuration,
error.Value, error.Value,
request.HlsRealtime, request.HlsRealtime,
@@ -491,11 +513,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
maybeDuration, maybeDuration,
finish, finish,
true); true,
now.ToUnixTimeSeconds());
default: default:
Command errorProcess = await _ffmpegProcessService.ForError( Command errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now,
maybeDuration, maybeDuration,
"Channel is Offline", "Channel is Offline",
request.HlsRealtime, request.HlsRealtime,
@@ -510,7 +534,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
maybeDuration, maybeDuration,
finish, finish,
true); true,
now.ToUnixTimeSeconds());
} }
} }
@@ -15,7 +15,7 @@ public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
false, false,
true, true,
DateTimeOffset.Now, // unused DateTimeOffset.Now, // unused
0) TimeSpan.Zero)
{ {
Scheme = scheme; Scheme = scheme;
Host = host; Host = host;
@@ -44,6 +44,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
Option<TimeSpan>.None, Option<TimeSpan>.None,
DateTimeOffset.MaxValue, DateTimeOffset.MaxValue,
true); true,
Option<long>.None);
} }
} }
@@ -67,7 +67,7 @@ public class PrepareTroubleshootingPlaybackHandler(
catch (Exception ex) catch (Exception ex)
{ {
entityLocker.UnlockTroubleshootingPlayback(); entityLocker.UnlockTroubleshootingPlayback();
await mediator.Publish(new PlaybackTroubleshootingCompletedNotification(-1), cancellationToken); await mediator.Publish(new PlaybackTroubleshootingCompletedNotification(-1, ex, Option<double>.None), cancellationToken);
logger.LogError(ex, "Error while preparing troubleshooting playback"); logger.LogError(ex, "Error while preparing troubleshooting playback");
return BaseError.New(ex.Message); return BaseError.New(ex.Message);
} }
@@ -246,12 +246,12 @@ public class PrepareTroubleshootingPlaybackHandler(
mediaItem is RemoteStream { IsLive: true } ? StreamInputKind.Live : StreamInputKind.Vod, mediaItem is RemoteStream { IsLive: true } ? StreamInputKind.Live : StreamInputKind.Vod,
FillerKind.None, FillerKind.None,
inPoint, inPoint,
outPoint,
channelStartTime: DateTimeOffset.Now, channelStartTime: DateTimeOffset.Now,
0, TimeSpan.Zero,
None, Option<int>.None,
FileSystemLayout.TranscodeTroubleshootingFolder, FileSystemLayout.TranscodeTroubleshootingFolder,
_ => { }, _ => { },
canProxy: true,
cancellationToken); cancellationToken);
return playoutItemResult; return playoutItemResult;
@@ -293,6 +293,10 @@ public class PrepareTroubleshootingPlaybackHandler(
return [subtitle]; return [subtitle];
} }
} }
else if (string.IsNullOrWhiteSpace(request.StreamSelector))
{
allSubtitles.Clear();
}
return allSubtitles; return allSubtitles;
} }
@@ -2,6 +2,7 @@ using System.IO.Pipelines;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using CliWrap; using CliWrap;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@@ -16,7 +17,7 @@ using Serilog.Events;
namespace ErsatzTV.Application.Troubleshooting; namespace ErsatzTV.Application.Troubleshooting;
public class StartTroubleshootingPlaybackHandler( public partial class StartTroubleshootingPlaybackHandler(
ITroubleshootingNotifier notifier, ITroubleshootingNotifier notifier,
IMediator mediator, IMediator mediator,
IEntityLocker entityLocker, IEntityLocker entityLocker,
@@ -134,6 +135,7 @@ public class StartTroubleshootingPlaybackHandler(
} }
CommandResult commandResult = await processWithPipe CommandResult commandResult = await processWithPipe
.WithWorkingDirectory(FileSystemLayout.TranscodeTroubleshootingFolder)
.WithStandardErrorPipe(PipeTarget.Null) .WithStandardErrorPipe(PipeTarget.Null)
.WithValidation(CommandResultValidation.None) .WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token); .ExecuteAsync(linkedCts.Token);
@@ -152,8 +154,26 @@ public class StartTroubleshootingPlaybackHandler(
// do nothing // do nothing
} }
Option<double> maybeSpeed = Option<double>.None;
Option<string> maybeFile = Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "ffmpeg*.log").HeadOrNone();
foreach (string file in maybeFile)
{
await foreach (string line in File.ReadLinesAsync(file, linkedCts.Token))
{
Match match = FFmpegSpeed().Match(line);
if (match.Success && double.TryParse(match.Groups[1].Value, out double speed))
{
maybeSpeed = speed;
break;
}
}
}
await mediator.Publish( await mediator.Publish(
new PlaybackTroubleshootingCompletedNotification(commandResult.ExitCode), new PlaybackTroubleshootingCompletedNotification(
commandResult.ExitCode,
Option<Exception>.None,
maybeSpeed),
linkedCts.Token); linkedCts.Token);
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode); logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode);
@@ -186,4 +206,7 @@ public class StartTroubleshootingPlaybackHandler(
loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = currentStreamingLevel; loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = currentStreamingLevel;
} }
} }
[GeneratedRegex(@"speed=\s*([\d\.]+)x", RegexOptions.IgnoreCase)]
private static partial Regex FFmpegSpeed();
} }
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Troubleshooting;
public record PlayoutHistoryDetailsViewModel(
PlaybackOrder PlaybackOrder,
CollectionType CollectionType,
string Name,
string MediaItemType,
string MediaItemTitle);
@@ -0,0 +1,5 @@
using ErsatzTV.Application.Playouts;
namespace ErsatzTV.Application.Troubleshooting.Queries;
public record DecodePlayoutHistory(PlayoutHistoryViewModel PlayoutHistory) : IRequest<PlayoutHistoryDetailsViewModel>;
@@ -0,0 +1,97 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace ErsatzTV.Application.Troubleshooting.Queries;
public class DecodePlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<DecodePlayoutHistory, PlayoutHistoryDetailsViewModel>
{
public async Task<PlayoutHistoryDetailsViewModel> Handle(
DecodePlayoutHistory request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var decodedKey = JsonConvert.DeserializeObject<BlockItemHistoryKey>(request.PlayoutHistory.Key);
PlaybackOrder playbackOrder = decodedKey.PlaybackOrder ?? PlaybackOrder.None;
CollectionType collectionType = decodedKey.CollectionType ?? CollectionType.Collection;
string name = string.Empty;
switch (collectionType)
{
case CollectionType.Collection:
name = await dbContext.Collections
.AsNoTracking()
.Where(c => c.Id == (decodedKey.CollectionId ?? 0))
.Map(c => c.Name)
.FirstOrDefaultAsync(cancellationToken);
break;
case CollectionType.SmartCollection:
name = await dbContext.SmartCollections
.AsNoTracking()
.Where(c => c.Id == (decodedKey.SmartCollectionId ?? 0))
.Map(c => c.Name)
.FirstOrDefaultAsync(cancellationToken);
break;
}
string mediaItemType = string.Empty;
string mediaItemTitle = string.Empty;
Details details = JsonConvert.DeserializeObject<Details>(request.PlayoutHistory.Details);
if (details?.MediaItemId != null)
{
Option<MediaItem> maybeMediaItem = await dbContext.MediaItems
.AsNoTracking()
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.ThenInclude(l => l.MediaSource)
.Include(i => (i as Movie).MovieMetadata)
.Include(i => (i as Episode).EpisodeMetadata)
.Include(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => (i as OtherVideo).OtherVideoMetadata)
.Include(i => (i as Image).ImageMetadata)
.Include(i => (i as RemoteStream).RemoteStreamMetadata)
.Include(i => (i as Song).SongMetadata)
.Include(i => (i as MusicVideo).MusicVideoMetadata)
.Include(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.SelectOneAsync(i => i.Id, i => i.Id == details.MediaItemId, cancellationToken);
foreach (var mediaItem in maybeMediaItem)
{
mediaItemType = mediaItem switch
{
Episode => "Episode",
Movie => "Movie",
MusicVideo => "Music Video",
OtherVideo => "Other Video",
Song => "Song",
Image => "Image",
RemoteStream => "Remote Stream",
_ => $"Unknown ({mediaItem.GetType().Name})"
};
mediaItemTitle = Playouts.Mapper.GetDisplayTitle(mediaItem, Option<string>.None);
}
}
return new PlayoutHistoryDetailsViewModel(playbackOrder, collectionType, name, mediaItemType, mediaItemTitle);
}
private sealed record BlockItemHistoryKey(
int? BlockId,
PlaybackOrder? PlaybackOrder,
CollectionType? CollectionType,
int? CollectionId,
int? SmartCollectionId);
private sealed record Details(int? MediaItemId);
}
@@ -10,11 +10,11 @@
<PackageReference Include="Bugsnag" Version="4.1.0" /> <PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.9.0" /> <PackageReference Include="CliWrap" Version="3.9.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" /> <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.9" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.9" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.9" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -22,7 +22,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" /> <PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.1.0" /> <PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="Serilog" Version="4.3.0" /> <PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.2" /> <PackageReference Include="Serilog.Extensions.Logging" Version="9.0.2" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" /> <PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
@@ -29,7 +29,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -43,14 +42,13 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegProfile ffmpegProfile = TestProfile(); FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings( FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings(
StreamingMode.HttpLiveStreamingSegmenterFmp4, StreamingMode.HttpLiveStreamingSegmenter,
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -75,7 +73,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -96,7 +93,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -110,14 +106,13 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 }; FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 };
FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings( FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings(
StreamingMode.HttpLiveStreamingSegmenterFmp4, StreamingMode.HttpLiveStreamingSegmenter,
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -138,7 +133,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -161,7 +155,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -184,7 +177,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -205,7 +197,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -228,7 +219,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
now, now,
now.AddMinutes(5), now.AddMinutes(5),
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -252,7 +242,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
now, now,
now.AddMinutes(5), now.AddMinutes(5),
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -280,7 +269,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -307,7 +295,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -334,7 +321,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -362,7 +348,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -389,7 +374,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -419,7 +403,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -450,7 +433,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -480,7 +462,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -510,7 +491,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -540,7 +520,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -572,7 +551,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -603,7 +581,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -634,7 +611,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -664,7 +640,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -695,7 +670,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -725,7 +699,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -756,7 +729,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -782,7 +754,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -806,7 +777,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -830,7 +800,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -855,7 +824,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -880,7 +848,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -905,7 +872,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -930,7 +896,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -954,7 +919,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -978,7 +942,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -987,7 +950,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
} }
[Test] [Test]
public void Should_SetAudioDuration_With_CorrectFormat_ForTransportStream() public void Should_SetPadAudio_ForTransportStream()
{ {
FFmpegProfile ffmpegProfile = TestProfile() with FFmpegProfile ffmpegProfile = TestProfile() with
{ {
@@ -1008,12 +971,11 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.FromMinutes(2),
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
actual.AudioDuration.IfNone(TimeSpan.MinValue).ShouldBe(TimeSpan.FromMinutes(2)); actual.PadAudio.ShouldBe(true);
} }
[Test] [Test]
@@ -1032,7 +994,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
@@ -1058,7 +1019,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
TimeSpan.Zero,
false, false,
StreamInputKind.Vod, StreamInputKind.Vod,
None); None);
File diff suppressed because it is too large Load Diff
@@ -70,6 +70,8 @@ public class FakeLocalFileSystem : ILocalFileSystem
.IfNoneAsync(string.Empty) .IfNoneAsync(string.Empty)
.Map(s => s.Split(Environment.NewLine)); .Map(s => s.Split(Environment.NewLine));
public Task<byte[]> GetHash(string path) => throw new NotSupportedException();
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask(); public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
private static List<DirectoryInfo> Split(DirectoryInfo path) private static List<DirectoryInfo> Split(DirectoryInfo path)
@@ -484,8 +484,164 @@ public class BlockPlayoutBuilderTests
result.AddedItems[0].StartOffset.TimeOfDay.ShouldBe(TimeSpan.FromHours(9)); result.AddedItems[0].StartOffset.TimeOfDay.ShouldBe(TimeSpan.FromHours(9));
} }
} }
[Test]
[CancelAfter(10_000)]
public async Task Should_Skip_Block_With_Empty_Collection(CancellationToken cancellationToken)
{
var collection = new SmartCollection
{
Id = 1,
Query = "asdf"
};
var collection2 = new SmartCollection
{
Id = 2,
Query = "asdf2"
};
var block = new Block
{
Id = 1,
Name = "Test Block",
Minutes = 30,
Items =
[
new BlockItem
{
Id = 1,
CollectionType = CollectionType.SmartCollection,
PlaybackOrder = PlaybackOrder.Chronological,
Index = 1,
SmartCollection = collection,
SmartCollectionId = collection.Id
},
new BlockItem
{
Id = 2,
CollectionType = CollectionType.SmartCollection,
PlaybackOrder = PlaybackOrder.Chronological,
Index = 2,
SmartCollection = collection2,
SmartCollectionId = collection2.Id
}
],
StopScheduling = BlockStopScheduling.BeforeDurationEnd
};
var template = new Template
{
Id = 1,
Items = []
};
var templateItem = new TemplateItem
{
Block = block,
BlockId = block.Id,
StartTime = TimeSpan.FromHours(9),
Template = template,
TemplateId = template.Id
};
template.Items.Add(templateItem);
var playoutTemplate = new PlayoutTemplate
{
Id = 1,
Index = 1,
Template = template,
TemplateId = template.Id,
DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(),
DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(),
MonthsOfYear = PlayoutTemplate.AllMonthsOfYear()
};
var playout = new Playout
{
Id = 1,
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Templates =
[
playoutTemplate
],
Items = [],
PlayoutHistory = []
};
var now = HoursAfterMidnight(9).AddMinutes(15);
var mediaItems = new List<MediaItem>();
var mediaItems2 = new List<MediaItem>
{
new Movie
{
Id = 2,
MovieMetadata = [new MovieMetadata { ReleaseDate = DateTime.Today.AddDays(1) }],
MediaVersions =
[
new MediaVersion
{
Duration = TimeSpan.FromMinutes(25),
MediaFiles = [new MediaFile { Path = "/fake/path/2" }]
}
]
}
};
var collectionRepo = new FakeMediaCollectionRepository(
new Map<int, List<MediaItem>>(
[
(collection.Id, mediaItems),
(collection2.Id, mediaItems2)
])
);
IConfigElementRepository configRepo = Substitute.For<IConfigElementRepository>();
configRepo
.GetValue<int>(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any<CancellationToken>())
.Returns(Some(1));
var builder = new BlockPlayoutBuilder(
configRepo,
collectionRepo,
Substitute.For<ITelevisionRepository>(),
Substitute.For<IArtistRepository>(),
Substitute.For<ICollectionEtag>(),
_logger);
var referenceData = new PlayoutReferenceData(
playout.Channel,
Option<Deco>.None,
[],
playout.Templates.ToList(),
null,
[],
[],
TimeSpan.Zero);
Either<BaseError, PlayoutBuildResult> buildResult = await builder.Build(
now,
playout,
referenceData,
PlayoutBuildMode.Reset,
cancellationToken);
buildResult.IsRight.ShouldBeTrue();
foreach (var result in buildResult.RightToSeq())
{
// this test only cares about "today"
result.AddedItems.RemoveAll(i => i.StartOffset.Date > now.Date);
result.AddedItems.Count.ShouldBe(1);
result.AddedItems[0].StartOffset.TimeOfDay.ShouldBe(TimeSpan.FromHours(9));
}
}
} }
protected static DateTimeOffset HoursAfterMidnight(int hours) protected static DateTimeOffset HoursAfterMidnight(int hours)
{ {
DateTimeOffset now = DateTimeOffset.Now; DateTimeOffset now = DateTimeOffset.Now;
@@ -0,0 +1,35 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling.Engine;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Scheduling.Engine;
[TestFixture]
public class SchedulingEngineTests
{
[Test]
public void Continue_Across_Time_Change()
{
var engine = new SchedulingEngine(
Substitute.For<IMediaCollectionRepository>(),
Substitute.For<IGraphicsElementRepository>(),
Substitute.For<IChannelRepository>(),
Substitute.For<ILogger<SchedulingEngine>>());
var anchor = new PlayoutAnchor
{
NextStart = new DateTimeOffset(new DateTime(2025, 10, 26), TimeSpan.FromHours(-5)).UtcDateTime
};
var start = new DateTimeOffset(new DateTime(2025, 11, 20), TimeSpan.FromHours(-6));
var finish = start.AddDays(1);
engine.BuildBetween(start, finish);
// should not throw
engine.RestoreOrReset(anchor);
}
}
@@ -59,7 +59,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
scheduleItem, scheduleItem,
new PlayoutItem(), new PlayoutItem(),
new List<MediaChapter>(), new List<MediaChapter>(),
true, new PlayoutBuildWarnings(),
_cancellationToken); _cancellationToken);
playoutItems.Count.ShouldBe(1); playoutItems.Count.ShouldBe(1);
@@ -112,7 +112,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
scheduleItem, scheduleItem,
new PlayoutItem(), new PlayoutItem(),
new List<MediaChapter> { new() }, new List<MediaChapter> { new() },
true, new PlayoutBuildWarnings(),
_cancellationToken); _cancellationToken);
playoutItems.Count.ShouldBe(1); playoutItems.Count.ShouldBe(1);
@@ -179,7 +179,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) }, new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) } new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) }
}, },
true, new PlayoutBuildWarnings(),
_cancellationToken); _cancellationToken);
playoutItems.Count.ShouldBe(3); playoutItems.Count.ShouldBe(3);
@@ -271,7 +271,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) }, new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) } new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
}, },
true, new PlayoutBuildWarnings(),
_cancellationToken); _cancellationToken);
playoutItems.Count.ShouldBe(5); playoutItems.Count.ShouldBe(5);
@@ -379,7 +379,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new MediaChapter { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(30) }, new MediaChapter { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(30) },
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) } new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
], ],
true, new PlayoutBuildWarnings(),
_cancellationToken); _cancellationToken);
playoutItems.Count.ShouldBe(5); playoutItems.Count.ShouldBe(5);
@@ -488,7 +488,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new MediaChapter { StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(30) }, new MediaChapter { StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(30) },
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) } new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
], ],
true, new PlayoutBuildWarnings(),
_cancellationToken); _cancellationToken);
playoutItems.Count.ShouldBe(6); playoutItems.Count.ShouldBe(6);
@@ -598,7 +598,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) }, new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) } new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
}, },
true, new PlayoutBuildWarnings(),
_cancellationToken); _cancellationToken);
playoutItems.Count.ShouldBe(5); playoutItems.Count.ShouldBe(5);
@@ -706,7 +706,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new MediaChapter { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(30) }, new MediaChapter { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(30) },
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) } new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
], ],
true, new PlayoutBuildWarnings(),
_cancellationToken); _cancellationToken);
playoutItems.Count.ShouldBe(5); playoutItems.Count.ShouldBe(5);
@@ -770,15 +770,13 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() new()
{ {
Id = id, Id = id,
MovieMetadata = new List<MovieMetadata> { new() { ReleaseDate = aired } }, MovieMetadata = [new MovieMetadata { ReleaseDate = aired }],
MediaVersions = new List<MediaVersion> MediaVersions = [new MediaVersion { Duration = duration }]
{
new() { Duration = duration }
}
}; };
private class TestScheduler : PlayoutModeSchedulerBase<ProgramScheduleItem> private class TestScheduler()
: PlayoutModeSchedulerBase<ProgramScheduleItem>(LoggerFactory.CreateLogger<TestScheduler>())
{ {
private static readonly ILoggerFactory LoggerFactory; private static readonly ILoggerFactory LoggerFactory;
@@ -792,11 +790,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
LoggerFactory = new LoggerFactory().AddSerilog(Log.Logger); LoggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
} }
public TestScheduler() : base(LoggerFactory.CreateLogger<TestScheduler>()) public override PlayoutSchedulerResult Schedule(
{
}
public override Tuple<PlayoutBuilderState, List<PlayoutItem>> Schedule(
PlayoutBuilderState playoutBuilderState, PlayoutBuilderState playoutBuilderState,
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators, Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
ProgramScheduleItem scheduleItem, ProgramScheduleItem scheduleItem,
@@ -60,7 +60,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger); var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -133,7 +133,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger); var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -205,7 +205,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger); var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -274,7 +274,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger); var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -357,7 +357,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger); var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -444,7 +444,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger); var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -543,7 +543,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger); var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -653,7 +653,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger); var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -802,7 +802,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger); var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -874,7 +874,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger); var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -51,7 +51,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -126,7 +126,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -223,7 +223,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -308,7 +308,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.PostRollFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.PostRollFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -396,7 +396,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -478,7 +478,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -576,7 +576,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -664,7 +664,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -772,7 +772,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -886,7 +886,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
// hard stop at 2, an hour before the "next schedule item" at 3 // hard stop at 2, an hour before the "next schedule item" at 3
DateTimeOffset hardStop = StartState(scheduleItemsEnumerator).CurrentTime.AddHours(2); DateTimeOffset hardStop = StartState(scheduleItemsEnumerator).CurrentTime.AddHours(2);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -992,7 +992,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
// hard stop at 2, an hour before the "next schedule item" at 3 // hard stop at 2, an hour before the "next schedule item" at 3
DateTimeOffset hardStop = StartState(scheduleItemsEnumerator).CurrentTime.AddHours(2); DateTimeOffset hardStop = StartState(scheduleItemsEnumerator).CurrentTime.AddHours(2);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -1104,7 +1104,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -1184,7 +1184,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -53,7 +53,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -155,7 +155,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -226,7 +226,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -301,7 +301,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -384,7 +384,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -483,7 +483,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -572,7 +572,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -682,7 +682,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -804,7 +804,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -890,7 +890,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -45,7 +45,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,
@@ -122,7 +122,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -196,7 +196,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -278,7 +278,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -350,7 +350,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2), CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
scheduleItem, scheduleItem,
@@ -442,7 +442,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -546,7 +546,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -632,7 +632,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -732,7 +732,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators( CollectionEnumerators(
scheduleItem, scheduleItem,
@@ -817,7 +817,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>()); var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule( (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
startState, startState,
CollectionEnumerators(scheduleItem, enumerator), CollectionEnumerators(scheduleItem, enumerator),
scheduleItem, scheduleItem,

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