Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb7bbf0e6 | ||
|
|
e851a295a6 | ||
|
|
3b254735e6 | ||
|
|
1f8834c280 | ||
|
|
545db4db9b | ||
|
|
e590298b93 | ||
|
|
a47510fef3 | ||
|
|
e089b12c2b | ||
|
|
82e0fcaec8 | ||
|
|
f7c699248c | ||
|
|
2d53063ce9 | ||
|
|
626048f9c3 | ||
|
|
2ef2b0299a | ||
|
|
fcce53a3df | ||
|
|
d4353f6d42 | ||
|
|
64ea413b6f | ||
|
|
d14ebf3522 | ||
|
|
889904e70d | ||
|
|
35e7922836 | ||
|
|
ffe15629cb | ||
|
|
ba5a027525 | ||
|
|
a33ac4a048 | ||
|
|
7ae028e2e9 | ||
|
|
6404dee646 | ||
|
|
940d26419c | ||
|
|
9bae8e73bf | ||
|
|
f41f4b19d4 | ||
|
|
917acf9683 | ||
|
|
da4687ac0f | ||
|
|
d1af6599f0 | ||
|
|
4e43817f8e | ||
|
|
ebcd9a35a7 | ||
|
|
ea5956a268 | ||
|
|
65ff1f5502 | ||
|
|
5ef8b04119 | ||
|
|
99837e808a | ||
|
|
7c2083d3f2 | ||
|
|
b851a7daba | ||
|
|
48e7c85f7b | ||
|
|
bf4182f115 | ||
|
|
fb9ca8953e | ||
|
|
48310e044b | ||
|
|
144b3fe80b | ||
|
|
4a754c4e6a | ||
|
|
7059669023 | ||
|
|
c03f81a465 | ||
|
|
e3d07050bf | ||
|
|
5a88bfc310 | ||
|
|
dd92a65742 | ||
|
|
07ffa1642b | ||
|
|
6fc602323f | ||
|
|
d5fd8e7be6 | ||
|
|
dba5485300 | ||
|
|
6847a133ca | ||
|
|
fd60c120ae | ||
|
|
371d1d89fb | ||
|
|
ec6bc797f4 | ||
|
|
4c57167864 | ||
|
|
9016523757 | ||
|
|
6a38c91d54 | ||
|
|
9ec4d0a85c | ||
|
|
4cb98242ba | ||
|
|
0e2084838a | ||
|
|
f3f900b4ca | ||
|
|
c39858b2d8 | ||
|
|
0363609923 | ||
|
|
a02aa37957 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.2.2.1",
|
||||
"version": "2025.2.3",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
|
||||
@@ -303,7 +303,7 @@ jobs:
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
(cd "${release_name}" && zip -r "../${release_name}.zip" .)
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
|
||||
+86
-2
@@ -5,7 +5,89 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [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
|
||||
- Add new collection type `Rerun Collection`
|
||||
- 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.
|
||||
|
||||
|
||||
[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.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
|
||||
|
||||
@@ -45,6 +45,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
}
|
||||
|
||||
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
|
||||
|
||||
@@ -67,7 +69,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
|
||||
string songTemplateFileName = GetSongTemplateFileName();
|
||||
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
|
||||
if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null ||
|
||||
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
|
||||
musicVideoTemplateFileName is null ||
|
||||
songTemplateFileName is null || otherVideoTemplateFileName is null)
|
||||
{
|
||||
return;
|
||||
@@ -104,7 +107,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.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);
|
||||
.SelectOneAsync(
|
||||
c => c.Number == request.ChannelNumber,
|
||||
c => c.Number == request.ChannelNumber,
|
||||
cancellationToken);
|
||||
foreach (Channel channel in maybeChannel)
|
||||
{
|
||||
mirrorChannelNumber = channel.MirrorSourceChannel.Number;
|
||||
@@ -120,6 +126,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.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)
|
||||
@@ -219,6 +229,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
floodSorted,
|
||||
@@ -243,6 +254,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WriteBlockPlayoutXml(
|
||||
request,
|
||||
blockSorted,
|
||||
@@ -265,6 +277,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
item.Start += playoutOffset;
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WritePlayoutXml(
|
||||
request,
|
||||
externalJsonSorted,
|
||||
@@ -288,6 +301,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WritePlayoutXml(
|
||||
RefreshChannelData request,
|
||||
@@ -653,6 +671,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
showMetadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(showMetadata);
|
||||
string thumbnailPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
@@ -673,6 +692,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
|
||||
EpisodeArtworkUrl = artworkPath,
|
||||
EpisodeHasThumbnail = !string.IsNullOrWhiteSpace(thumbnailPath),
|
||||
EpisodeThumbnailUrl = thumbnailPath,
|
||||
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
|
||||
metadata.EpisodeNumber,
|
||||
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
|
||||
|
||||
@@ -81,8 +81,6 @@ internal static class Mapper
|
||||
StreamingMode.TransportStreamHybrid => "MPEG-TS",
|
||||
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
||||
StreamingMode.HttpLiveStreamingSegmenterFmp4 => "HLS Segmenter (fmp4)",
|
||||
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(channel))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,17 +5,14 @@ using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
|
||||
public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetAllChannelsForApiHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public async Task<List<ChannelResponseModel>> Handle(
|
||||
GetAllChannelsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
return channels.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,11 @@ using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetChannelPlaylistHandler(IChannelRepository channelRepository) =>
|
||||
_channelRepository = channelRepository;
|
||||
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll(cancellationToken)
|
||||
channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(
|
||||
request.Scheme,
|
||||
@@ -38,14 +34,6 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "segmenter-fmp4":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterFmp4;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "segmenter-v2":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterV2;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "hls-direct":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
|
||||
result.Add(channel);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -14,21 +15,23 @@ namespace ErsatzTV.Application.Emby;
|
||||
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
|
||||
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -40,7 +43,7 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -49,7 +52,7 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId, cancellationToken)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -67,13 +70,20 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.EmbyCollections);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-collections", request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture)
|
||||
"scan-emby-collections",
|
||||
request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
@@ -81,6 +91,19 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New("Emby collections are already scanning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -15,14 +16,16 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle(
|
||||
@@ -38,9 +41,9 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -53,13 +56,20 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby", request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
"scan-emby",
|
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
@@ -72,19 +82,32 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
Option<EmbyLibrary> maybeLibrary = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId, cancellationToken);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
DateTime minDateTime = maybeLibrary.Match(
|
||||
l => l.LastScan ?? SystemTime.MinValueUtc,
|
||||
() => SystemTime.MaxValueUtc);
|
||||
|
||||
string libraryName = maybeLibrary.Match(l => l.Name, string.Empty);
|
||||
|
||||
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -13,14 +13,16 @@ namespace ErsatzTV.Application.Emby;
|
||||
public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyShowById>,
|
||||
IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallEmbyShowScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle(
|
||||
@@ -31,9 +33,9 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -46,15 +48,21 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.EmbyLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-show",
|
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture)
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.DeepScan)
|
||||
@@ -62,14 +70,22 @@ public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeE
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan(
|
||||
return BaseError.New($"Library {request.EmbyLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeEmbyShowById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DateTimeOffset.MinValue);
|
||||
Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
@@ -12,16 +13,19 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
|
||||
private readonly IEmbyApiClient _embyApiClient;
|
||||
private readonly IEmbySecretStore _embySecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public SaveEmbySecretsHandler(
|
||||
IEmbySecretStore embySecretStore,
|
||||
IEmbyApiClient embyApiClient,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IMemoryCache memoryCache,
|
||||
ChannelWriter<IEmbyBackgroundServiceRequest> channel)
|
||||
{
|
||||
_embySecretStore = embySecretStore;
|
||||
_embyApiClient = embyApiClient;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_memoryCache = memoryCache;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ public class SaveEmbySecretsHandler : IRequestHandler<SaveEmbySecrets, Either<Ba
|
||||
parameters.Secrets.Address,
|
||||
parameters.ServerInformation.ServerName,
|
||||
parameters.ServerInformation.OperatingSystem);
|
||||
_memoryCache.Remove(new GetEmbyConnectionParameters());
|
||||
await _channel.WriteAsync(new SynchronizeEmbyMediaSources());
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -2,5 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
: IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<PackageReference Include="CliWrap" Version="3.9.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -7,23 +8,18 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
|
||||
public class DeleteFFmpegProfileHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
|
||||
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
}
|
||||
|
||||
@@ -31,10 +27,19 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
|
||||
{
|
||||
dbContext.FFmpegProfiles.Remove(ffmpegProfile);
|
||||
await dbContext.SaveChangesAsync();
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
searchTargets.SearchTargetsChanged();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken),
|
||||
await FFmpegProfileMustNotBeDefault(request, cancellationToken),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((_, _, ffmpegProfile) => ffmpegProfile);
|
||||
|
||||
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
@@ -42,4 +47,38 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
|
||||
dbContext.FFmpegProfiles
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.FFmpegProfileId == request.FFmpegProfileId)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Cannot delete FFmpeg Profile that is used by {count} {(count > 1 ? "channels" : "channel")}");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeDefault(
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> defaultFFmpegProfileId =
|
||||
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId, cancellationToken);
|
||||
|
||||
if (defaultFFmpegProfileId.Any(id => id == request.FFmpegProfileId))
|
||||
{
|
||||
return BaseError.New("Cannot delete default FFmpeg Profile");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.Preset;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -11,23 +9,14 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class
|
||||
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
|
||||
public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
: IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
|
||||
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
|
||||
UpdateFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
|
||||
}
|
||||
@@ -89,7 +78,7 @@ public class
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
return new UpdateFFmpegProfileResult(p.Id);
|
||||
}
|
||||
@@ -98,7 +87,8 @@ public class
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustExist(dbContext, request, cancellationToken), ValidateName(request),
|
||||
(await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateName(dbContext, request),
|
||||
ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
|
||||
@@ -111,9 +101,25 @@ public class
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
updateFFmpegProfile.NotEmpty(x => x.Name)
|
||||
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name));
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile)
|
||||
{
|
||||
if (updateFFmpegProfile.Name.Length > 50)
|
||||
{
|
||||
return BaseError.New($"FFmpeg profile name \"{updateFFmpegProfile.Name}\" is invalid");
|
||||
}
|
||||
|
||||
Option<FFmpegProfile> maybeExisting = await dbContext.FFmpegProfiles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(ff =>
|
||||
ff.Id != updateFFmpegProfile.FFmpegProfileId && ff.Name == updateFFmpegProfile.Name)
|
||||
.Map(Optional);
|
||||
|
||||
return maybeExisting.IsSome
|
||||
? BaseError.New($"An ffmpeg profile named \"{updateFFmpegProfile.Name}\" already exists in the database")
|
||||
: Success<BaseError, string>(updateFFmpegProfile.Name);
|
||||
}
|
||||
|
||||
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
|
||||
|
||||
@@ -47,19 +47,25 @@ internal static class Mapper
|
||||
ffmpegProfile.Id,
|
||||
ffmpegProfile.Name,
|
||||
ffmpegProfile.ThreadCount,
|
||||
(int)ffmpegProfile.HardwareAcceleration,
|
||||
ffmpegProfile.HardwareAcceleration,
|
||||
ffmpegProfile.VaapiDisplay,
|
||||
(int)ffmpegProfile.VaapiDriver,
|
||||
ffmpegProfile.VaapiDriver,
|
||||
ffmpegProfile.VaapiDevice,
|
||||
ffmpegProfile.ResolutionId,
|
||||
(int)ffmpegProfile.VideoFormat,
|
||||
ffmpegProfile.QsvExtraHardwareFrames,
|
||||
ffmpegProfile.Resolution.Name,
|
||||
ffmpegProfile.ScalingBehavior,
|
||||
ffmpegProfile.VideoFormat,
|
||||
ffmpegProfile.VideoProfile,
|
||||
ffmpegProfile.VideoPreset,
|
||||
ffmpegProfile.AllowBFrames,
|
||||
ffmpegProfile.BitDepth,
|
||||
ffmpegProfile.VideoBitrate,
|
||||
ffmpegProfile.VideoBufferSize,
|
||||
(int)ffmpegProfile.TonemapAlgorithm,
|
||||
(int)ffmpegProfile.AudioFormat,
|
||||
ffmpegProfile.TonemapAlgorithm,
|
||||
ffmpegProfile.AudioFormat,
|
||||
ffmpegProfile.AudioBitrate,
|
||||
ffmpegProfile.AudioBufferSize,
|
||||
(int)ffmpegProfile.NormalizeLoudnessMode,
|
||||
ffmpegProfile.NormalizeLoudnessMode,
|
||||
ffmpegProfile.AudioChannels,
|
||||
ffmpegProfile.AudioSampleRate,
|
||||
ffmpegProfile.NormalizeFramerate,
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public record GetAllFFmpegProfilesForApi : IRequest<List<FFmpegProfileResponseModel>>;
|
||||
public record GetAllFFmpegProfilesForApi : IRequest<List<FFmpegFullProfileResponseModel>>;
|
||||
|
||||
@@ -6,23 +6,18 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class
|
||||
GetAllFFmpegProfilesForApiHandler : IRequestHandler<GetAllFFmpegProfilesForApi, List<FFmpegProfileResponseModel>>
|
||||
public class GetAllFFmpegProfilesForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllFFmpegProfilesForApi, List<FFmpegFullProfileResponseModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllFFmpegProfilesForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<FFmpegProfileResponseModel>> Handle(
|
||||
public async Task<List<FFmpegFullProfileResponseModel>> Handle(
|
||||
GetAllFFmpegProfilesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<FFmpegProfile> ffmpegProfiles = await dbContext.FFmpegProfiles
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Resolution)
|
||||
.ToListAsync(cancellationToken);
|
||||
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
|
||||
return ffmpegProfiles.Map(ProjectToFullResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -14,21 +15,23 @@ namespace ErsatzTV.Application.Jellyfin;
|
||||
public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinCollections>,
|
||||
IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallJellyfinCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -40,7 +43,7 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeJellyfinCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -49,7 +52,7 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId, cancellationToken)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -67,13 +70,20 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizeJellyfinCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.JellyfinCollections);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-jellyfin-collections", request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture)
|
||||
"scan-jellyfin-collections",
|
||||
request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId),
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
@@ -81,6 +91,19 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New("Jellyfin collections are already scanning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -15,14 +16,16 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallJellyfinLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>.
|
||||
@@ -39,9 +42,9 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -54,13 +57,20 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.JellyfinLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-jellyfin", request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
"scan-jellyfin",
|
||||
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
@@ -73,19 +83,32 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
return BaseError.New($"Library {request.JellyfinLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.JellyfinLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId, cancellationToken)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
Option<JellyfinLibrary> maybeLibrary = await dbContext.JellyfinLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId, cancellationToken);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
DateTime minDateTime = maybeLibrary.Match(
|
||||
l => l.LastScan ?? SystemTime.MinValueUtc,
|
||||
() => SystemTime.MaxValueUtc);
|
||||
|
||||
string libraryName = maybeLibrary.Match(l => l.Name, string.Empty);
|
||||
|
||||
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -13,14 +13,16 @@ namespace ErsatzTV.Application.Jellyfin;
|
||||
public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinShowById>,
|
||||
IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallJellyfinShowScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>.Handle(
|
||||
@@ -31,9 +33,9 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
|
||||
SynchronizeJellyfinShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -46,15 +48,21 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizeJellyfinShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.JellyfinLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-jellyfin-show",
|
||||
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture)
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.DeepScan)
|
||||
@@ -62,14 +70,22 @@ public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<Synchron
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan(
|
||||
return BaseError.New($"Library {request.JellyfinLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeJellyfinShowById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DateTimeOffset.MinValue);
|
||||
Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
@@ -12,16 +13,19 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public SaveJellyfinSecretsHandler(
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IMemoryCache memoryCache,
|
||||
ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
|
||||
{
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_memoryCache = memoryCache;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
|
||||
parameters.Secrets.Address,
|
||||
parameters.ServerInformation.ServerName,
|
||||
parameters.ServerInformation.OperatingSystem);
|
||||
_memoryCache.Remove(new GetJellyfinConnectionParameters());
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinMediaSources());
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) :
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan) :
|
||||
IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Channels;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.MediaSources;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
@@ -19,34 +14,15 @@ using Serilog.Formatting.Compact.Reader;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public abstract class CallLibraryScannerHandler<TRequest>
|
||||
{
|
||||
private readonly int _batchSize = 100;
|
||||
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private readonly List<int> _toReindex = [];
|
||||
private readonly List<int> _toRemove = [];
|
||||
private string _libraryName;
|
||||
|
||||
protected CallLibraryScannerHandler(
|
||||
public abstract class CallLibraryScannerHandler<TRequest>(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_configElementRepository = configElementRepository;
|
||||
_channel = channel;
|
||||
_mediator = mediator;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/scan/{scanId}";
|
||||
|
||||
protected async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
List<string> arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -55,39 +31,26 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
using var forcefulCts = new CancellationTokenSource();
|
||||
|
||||
await using CancellationTokenRegistration link =
|
||||
cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10))
|
||||
);
|
||||
cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10)));
|
||||
|
||||
CommandResult process = await Cli.Wrap(scanner)
|
||||
CommandResult process = await Cli.Wrap(parameters.Scanner)
|
||||
.WithArguments(arguments)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithStandardErrorPipe(PipeTarget.ToDelegate(ProcessLogOutput))
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(ProcessProgressOutput))
|
||||
.WithStandardOutputPipe(PipeTarget.Null)
|
||||
.ExecuteAsync(forcefulCts.Token, cancellationToken);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
|
||||
}
|
||||
|
||||
if (_toReindex.Count > 0)
|
||||
{
|
||||
await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray()), cancellationToken);
|
||||
_toReindex.Clear();
|
||||
}
|
||||
|
||||
if (_toRemove.Count > 0)
|
||||
{
|
||||
await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray()), cancellationToken);
|
||||
_toRemove.Clear();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return _libraryName ?? string.Empty;
|
||||
return parameters.LibraryName;
|
||||
}
|
||||
|
||||
private static void ProcessLogOutput(string s)
|
||||
@@ -124,74 +87,32 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessProgressOutput(string s)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
try
|
||||
{
|
||||
ScannerProgressUpdate progressUpdate = JsonConvert.DeserializeObject<ScannerProgressUpdate>(s);
|
||||
if (progressUpdate != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(progressUpdate.LibraryName))
|
||||
{
|
||||
_libraryName = progressUpdate.LibraryName;
|
||||
}
|
||||
|
||||
_toReindex.AddRange(progressUpdate.ItemsToReindex);
|
||||
if (_toReindex.Count >= _batchSize)
|
||||
{
|
||||
await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray()));
|
||||
_toReindex.Clear();
|
||||
}
|
||||
|
||||
_toRemove.AddRange(progressUpdate.ItemsToRemove);
|
||||
if (_toRemove.Count >= _batchSize)
|
||||
{
|
||||
await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray()));
|
||||
_toRemove.Clear();
|
||||
}
|
||||
|
||||
if (progressUpdate.PercentComplete is not null)
|
||||
{
|
||||
var progress = new LibraryScanProgress(
|
||||
progressUpdate.LibraryId,
|
||||
progressUpdate.PercentComplete.Value);
|
||||
|
||||
await _mediator.Publish(progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "Unable to process scanner progress update");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract Task<DateTimeOffset> GetLastScan(
|
||||
protected abstract Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
TRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
|
||||
|
||||
protected async Task<Validation<BaseError, string>> Validate(TRequest request, CancellationToken cancellationToken)
|
||||
protected async Task<Validation<BaseError, ScanParameters>> Validate(TRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
int libraryRefreshInterval = await _configElementRepository
|
||||
try
|
||||
{
|
||||
int libraryRefreshInterval = await configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
|
||||
.IfNoneAsync(0);
|
||||
|
||||
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
DateTimeOffset lastScan = await GetLastScan(dbContext, request, cancellationToken);
|
||||
(string libraryName, DateTimeOffset lastScan) = await GetLastScan(dbContext, request, cancellationToken);
|
||||
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
|
||||
{
|
||||
return new ScanIsNotRequired();
|
||||
}
|
||||
|
||||
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
|
||||
string executable = runtimeInfo.IsOSPlatform(OSPlatform.Windows)
|
||||
? "ErsatzTV.Scanner.exe"
|
||||
: "ErsatzTV.Scanner";
|
||||
|
||||
@@ -208,10 +129,17 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
string localFileName = Path.Combine(folderName, executable);
|
||||
if (File.Exists(localFileName))
|
||||
{
|
||||
return 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.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record PreviewPlaylistPlayout(ReplacePlaylistItems Data) : IRequest<List<PlayoutItemPreviewViewModel>>;
|
||||
public record PreviewPlaylistPlayout(ReplacePlaylistItems Data)
|
||||
: IRequest<Either<BaseError, List<PlayoutItemPreviewViewModel>>>;
|
||||
|
||||
@@ -17,9 +17,9 @@ public class PreviewPlaylistPlayoutHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
IPlayoutBuilder playoutBuilder)
|
||||
: IRequestHandler<PreviewPlaylistPlayout, List<PlayoutItemPreviewViewModel>>
|
||||
: IRequestHandler<PreviewPlaylistPlayout, Either<BaseError, List<PlayoutItemPreviewViewModel>>>
|
||||
{
|
||||
public async Task<List<PlayoutItemPreviewViewModel>> Handle(
|
||||
public async Task<Either<BaseError, List<PlayoutItemPreviewViewModel>>> Handle(
|
||||
PreviewPlaylistPlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -65,7 +65,7 @@ public class PreviewPlaylistPlayoutHandler(
|
||||
PlayoutBuildMode.Reset,
|
||||
cancellationToken);
|
||||
|
||||
return await buildResult.MatchAsync(
|
||||
return await buildResult.MatchAsync<Either<BaseError, List<PlayoutItemPreviewViewModel>>>(
|
||||
async result =>
|
||||
{
|
||||
var maxItems = 0;
|
||||
@@ -121,7 +121,7 @@ public class PreviewPlaylistPlayoutHandler(
|
||||
|
||||
return onceThrough.OrderBy(i => i.StartOffset).Map(Scheduling.Mapper.ProjectToViewModel).ToList();
|
||||
},
|
||||
_ => []);
|
||||
error => error);
|
||||
}
|
||||
|
||||
private static ProgramScheduleItemFlood MapToScheduleItem(PreviewPlaylistPlayout request) =>
|
||||
|
||||
@@ -195,7 +195,8 @@ public abstract class TraktCommandBase
|
||||
Index = item.Rank,
|
||||
PlaylistId = list.Playlist.Id,
|
||||
Playlist = list.Playlist,
|
||||
IncludeInProgramGuide = true
|
||||
IncludeInProgramGuide = true,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
};
|
||||
|
||||
await dbContext.PlaylistItems.AddAsync(playlistItem, cancellationToken);
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record UpdateSmartCollection(int Id, string Name, string Query) : IRequest<Either<BaseError, Unit>>;
|
||||
public record UpdateSmartCollection(int Id, string Name, string Query)
|
||||
: IRequest<Either<BaseError, UpdateSmartCollectionResult>>;
|
||||
|
||||
@@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
|
||||
public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, UpdateSmartCollectionResult>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
@@ -34,7 +34,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
|
||||
_smartCollectionCache = smartCollectionCache;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
public async Task<Either<BaseError, UpdateSmartCollectionResult>> Handle(
|
||||
UpdateSmartCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -43,7 +43,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
SmartCollection c,
|
||||
UpdateSmartCollection request,
|
||||
@@ -65,7 +65,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
return new UpdateSmartCollectionResult(c.Id);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, SmartCollection>> Validate(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record UpdateSmartCollectionResult(int SmartCollectionId) : EntityIdResult(SmartCollectionId);
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.Tree;
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
@@ -23,6 +24,9 @@ internal static class Mapper
|
||||
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
|
||||
new(collection.Id, collection.Name, collection.Query);
|
||||
|
||||
internal static SmartCollectionResponseModel ProjectToResponseModel(SmartCollection collection) =>
|
||||
new(collection.Id, collection.Name, collection.Query);
|
||||
|
||||
internal static RerunCollectionViewModel ProjectToViewModel(RerunCollection collection) =>
|
||||
new(
|
||||
collection.Id,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record GetAllSmartCollectionsForApi : IRequest<List<SmartCollectionResponseModel>>;
|
||||
@@ -0,0 +1,22 @@
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class GetAllSmartCollectionsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllSmartCollectionsForApi, List<SmartCollectionResponseModel>>
|
||||
{
|
||||
public async Task<List<SmartCollectionResponseModel>> Handle(
|
||||
GetAllSmartCollectionsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<SmartCollection> ffmpegProfiles = await dbContext.SmartCollections
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
@@ -15,14 +16,16 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
|
||||
IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
|
||||
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallLocalLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -50,13 +53,20 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
IScanLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.LibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-local", request.LibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
"scan-local",
|
||||
request.LibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
@@ -64,10 +74,18 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
return BaseError.New($"Library {request.LibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
IScanLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -80,7 +98,11 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
|
||||
? libraryPaths.Min(lp => lp.LastScan ?? SystemTime.MinValueUtc)
|
||||
: SystemTime.MaxValueUtc;
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
string libraryName = await dbContext.Libraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.LibraryId, cancellationToken)
|
||||
.Match(l => l.Name, () => string.Empty);
|
||||
|
||||
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
|
||||
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
@@ -29,6 +30,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
private readonly IPlayoutBuilder _playoutBuilder;
|
||||
private readonly IPlayoutTimeShifter _playoutTimeShifter;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly ILogger<BuildPlayoutHandler> _logger;
|
||||
private readonly ISequentialPlayoutBuilder _sequentialPlayoutBuilder;
|
||||
private readonly IScriptedPlayoutBuilder _scriptedPlayoutBuilder;
|
||||
|
||||
@@ -44,7 +46,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IEntityLocker entityLocker,
|
||||
IPlayoutTimeShifter playoutTimeShifter,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILogger<BuildPlayoutHandler> logger)
|
||||
{
|
||||
_client = client;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
@@ -58,6 +61,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
_entityLocker = entityLocker;
|
||||
_playoutTimeShifter = playoutTimeShifter;
|
||||
_workerChannel = workerChannel;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
|
||||
@@ -88,6 +92,38 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
{
|
||||
await _playoutTimeShifter.TimeShift(request.PlayoutId, timeShiftTo, false, cancellationToken);
|
||||
}
|
||||
|
||||
if (playoutBuildResult.Warnings.TailFillerTooLong > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Playout {PlayoutId} skipped {Count} tail filler items that were too long to fit",
|
||||
request.PlayoutId,
|
||||
playoutBuildResult.Warnings.TailFillerTooLong);
|
||||
}
|
||||
|
||||
if (playoutBuildResult.Warnings.MidRollContentWithoutChapters > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Playout {PlayoutId} converted mid-roll to post-roll for {Count} items that have no chapter markers",
|
||||
request.PlayoutId,
|
||||
playoutBuildResult.Warnings.MidRollContentWithoutChapters);
|
||||
}
|
||||
|
||||
if (playoutBuildResult.Warnings.DurationFillerSkipped > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Playout {PlayoutId} skipped {Count} filler items to try to fit in a small remaining duration",
|
||||
request.PlayoutId,
|
||||
playoutBuildResult.Warnings.DurationFillerSkipped);
|
||||
}
|
||||
|
||||
if (playoutBuildResult.Warnings.BlockItemSkippedEmptyCollection > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Playout {PlayoutId} skipped {Count} block items due to empty collections",
|
||||
request.PlayoutId,
|
||||
playoutBuildResult.Warnings.BlockItemSkippedEmptyCollection);
|
||||
}
|
||||
}
|
||||
|
||||
return result.Map(_ => Unit.Default);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
@@ -34,6 +35,14 @@ internal static class Mapper
|
||||
programScheduleAlternate.DaysOfMonth,
|
||||
programScheduleAlternate.MonthsOfYear);
|
||||
|
||||
internal static PlayoutHistoryViewModel ProjectToViewModel(PlayoutHistory playoutHistory) =>
|
||||
new(
|
||||
playoutHistory.Id,
|
||||
new DateTimeOffset(playoutHistory.When, TimeSpan.Zero).ToLocalTime(),
|
||||
new DateTimeOffset(playoutHistory.Finish, TimeSpan.Zero).ToLocalTime(),
|
||||
playoutHistory.Key,
|
||||
playoutHistory.Details);
|
||||
|
||||
internal static string GetDisplayTitle(MediaItem mediaItem, Option<string> maybeChapterTitle)
|
||||
{
|
||||
string chapterTitle = maybeChapterTitle.IfNone(string.Empty);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record PagedPlayoutHistoryViewModel(int TotalCount, List<PlayoutHistoryViewModel> Page);
|
||||
@@ -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.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -14,21 +15,23 @@ namespace ErsatzTV.Application.Plex;
|
||||
public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<SynchronizePlexCollections>,
|
||||
IRequestHandler<SynchronizePlexCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallPlexCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizePlexCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -40,7 +43,7 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizePlexCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -49,7 +52,7 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexMediaSourceId, cancellationToken)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -67,13 +70,20 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizePlexCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.PlexCollections);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-plex-collections", request.PlexMediaSourceId.ToString(CultureInfo.InvariantCulture)
|
||||
"scan-plex-collections",
|
||||
request.PlexMediaSourceId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
@@ -81,6 +91,19 @@ public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<Synchr
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New("Plex collections are already scanning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -15,14 +16,16 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallPlexLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>.Handle(
|
||||
@@ -38,9 +41,9 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -53,13 +56,20 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.PlexLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-plex", request.PlexLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
"scan-plex",
|
||||
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
@@ -72,19 +82,32 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
return BaseError.New($"Library {request.PlexLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.PlexLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId, cancellationToken)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
Option<PlexLibrary> maybeLibrary = await dbContext.PlexLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId, cancellationToken);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
DateTime minDateTime = maybeLibrary.Match(
|
||||
l => l.LastScan ?? SystemTime.MinValueUtc,
|
||||
() => SystemTime.MaxValueUtc);
|
||||
|
||||
string libraryName = maybeLibrary.Match(l => l.Name, () => string.Empty);
|
||||
|
||||
return new Tuple<string, DateTimeOffset>(libraryName, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -15,19 +16,21 @@ namespace ErsatzTV.Application.Plex;
|
||||
public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<SynchronizePlexNetworks>,
|
||||
IRequestHandler<SynchronizePlexNetworks, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallPlexNetworkScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizePlexNetworks request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
@@ -41,7 +44,7 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizePlexNetworks request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -51,7 +54,7 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId, cancellationToken)
|
||||
.Match(l => l.LastNetworksScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -69,13 +72,20 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizePlexNetworks request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.PlexNetworks);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-plex-networks", request.PlexLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
"scan-plex-networks",
|
||||
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
@@ -83,6 +93,14 @@ public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<Synchroni
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New("Plex networks are already scanning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -13,14 +13,16 @@ namespace ErsatzTV.Application.Plex;
|
||||
public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizePlexShowById>,
|
||||
IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IScannerProxyService _scannerProxyService;
|
||||
|
||||
public CallPlexShowScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IScannerProxyService scannerProxyService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, runtimeInfo)
|
||||
{
|
||||
_scannerProxyService = scannerProxyService;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>>.Handle(
|
||||
@@ -31,9 +33,9 @@ public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizeP
|
||||
SynchronizePlexShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request, cancellationToken);
|
||||
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
parameters => PerformScan(parameters, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
@@ -46,15 +48,21 @@ public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizeP
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ScanParameters parameters,
|
||||
SynchronizePlexShowById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeScanId = _scannerProxyService.StartScan(request.PlexLibraryId);
|
||||
foreach (var scanId in maybeScanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-plex-show",
|
||||
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture),
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture)
|
||||
request.ShowId.ToString(CultureInfo.InvariantCulture),
|
||||
GetBaseUrl(scanId)
|
||||
};
|
||||
|
||||
if (request.DeepScan)
|
||||
@@ -62,14 +70,22 @@ public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizeP
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
return await base.PerformScan(parameters, arguments, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scannerProxyService.EndScan(scanId);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan(
|
||||
return BaseError.New($"Library {request.PlexLibraryId} is already scanning");
|
||||
}
|
||||
|
||||
protected override Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizePlexShowById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DateTimeOffset.MinValue);
|
||||
Task.FromResult(new Tuple<string, DateTimeOffset>(string.Empty, DateTimeOffset.MinValue));
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
|
||||
@@ -2,5 +2,5 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
: IRequest<Either<BaseError, Unit>>, IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -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,
|
||||
Name = name,
|
||||
BreakContent = [],
|
||||
DecoWatermarks = [],
|
||||
DecoGraphicsElements = []
|
||||
});
|
||||
|
||||
@@ -10,4 +10,4 @@ public record ReplaceBlockItems(
|
||||
int Minutes,
|
||||
BlockStopScheduling StopScheduling,
|
||||
List<ReplaceBlockItem> Items)
|
||||
: IRequest<Either<BaseError, List<BlockItemViewModel>>>;
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -8,9 +8,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
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,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -19,7 +19,7 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
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,
|
||||
ReplaceBlockItems request,
|
||||
Block block,
|
||||
@@ -41,7 +41,7 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
// 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)
|
||||
|
||||
@@ -28,15 +28,6 @@ internal static class Mapper
|
||||
tg.Name,
|
||||
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) =>
|
||||
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 IGraphicsEngine _graphicsEngine;
|
||||
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
|
||||
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
|
||||
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<StartFFmpegSessionHandler> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<HlsSessionWorker> _sessionWorkerLogger;
|
||||
private readonly ILogger<HlsSessionWorkerV2> _sessionWorkerV2Logger;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public StartFFmpegSessionHandler(
|
||||
IHlsPlaylistFilter hlsPlaylistFilter,
|
||||
IHlsInitSegmentCache hlsInitSegmentCache,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IMediator mediator,
|
||||
IClient client,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<StartFFmpegSessionHandler> logger,
|
||||
ILogger<HlsSessionWorker> sessionWorkerLogger,
|
||||
ILogger<HlsSessionWorkerV2> sessionWorkerV2Logger,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IGraphicsEngine graphicsEngine,
|
||||
@@ -50,13 +50,13 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_hlsPlaylistFilter = hlsPlaylistFilter;
|
||||
_hlsInitSegmentCache = hlsInitSegmentCache;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_mediator = mediator;
|
||||
_client = client;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
_sessionWorkerLogger = sessionWorkerLogger;
|
||||
_sessionWorkerV2Logger = sessionWorkerV2Logger;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_configElementRepository = configElementRepository;
|
||||
_graphicsEngine = graphicsEngine;
|
||||
@@ -92,7 +92,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
|
||||
await _mediator.Send(new RefreshGraphicsElements(), cancellationToken);
|
||||
|
||||
IHlsSessionWorker worker = GetSessionWorker(request, targetFramerate);
|
||||
HlsSessionWorker worker = GetSessionWorker(request, targetFramerate);
|
||||
|
||||
_ffmpegSegmenterService.AddOrUpdateWorker(request.ChannelNumber, worker);
|
||||
|
||||
@@ -118,32 +118,16 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private IHlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<int> targetFramerate) =>
|
||||
private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<int> targetFramerate) =>
|
||||
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(
|
||||
_serviceScopeFactory,
|
||||
OutputFormatKind.Hls,
|
||||
_graphicsEngine,
|
||||
_client,
|
||||
OutputFormatKind.Hls,
|
||||
_hlsPlaylistFilter,
|
||||
_hlsInitSegmentCache,
|
||||
_configElementRepository,
|
||||
_localFileSystem,
|
||||
_sessionWorkerLogger,
|
||||
@@ -165,7 +149,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
request.ChannelNumber,
|
||||
out IHlsSessionWorker worker))
|
||||
{
|
||||
worker?.Touch();
|
||||
worker?.Touch(Option<string>.None);
|
||||
}
|
||||
|
||||
return result.AsTask();
|
||||
|
||||
@@ -26,8 +26,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
private static int _workAheadCount;
|
||||
private readonly IClient _client;
|
||||
private readonly OutputFormatKind _outputFormatKind;
|
||||
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
|
||||
private readonly Dictionary<long, int> _discontinuityMap = [];
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly OutputFormatKind _outputFormat;
|
||||
private readonly IGraphicsEngine _graphicsEngine;
|
||||
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
@@ -39,6 +41,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
private CancellationTokenSource _cancellationTokenSource;
|
||||
private string _channelNumber;
|
||||
private DateTimeOffset _channelStart;
|
||||
private int _discontinuitySequence;
|
||||
private bool _disposedValue;
|
||||
private bool _hasWrittenSegments;
|
||||
private DateTimeOffset _lastAccess;
|
||||
@@ -47,13 +50,15 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
private HlsSessionState _state;
|
||||
private Timer _timer;
|
||||
private DateTimeOffset _transcodedUntil;
|
||||
private string _workingDirectory;
|
||||
|
||||
public HlsSessionWorker(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
OutputFormatKind outputFormat,
|
||||
IGraphicsEngine graphicsEngine,
|
||||
IClient client,
|
||||
OutputFormatKind outputFormatKind,
|
||||
IHlsPlaylistFilter hlsPlaylistFilter,
|
||||
IHlsInitSegmentCache hlsInitSegmentCache,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<HlsSessionWorker> logger,
|
||||
@@ -61,9 +66,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
_serviceScope = serviceScopeFactory.CreateScope();
|
||||
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
_outputFormat = outputFormat;
|
||||
_graphicsEngine = graphicsEngine;
|
||||
_client = client;
|
||||
_outputFormatKind = outputFormatKind;
|
||||
_hlsInitSegmentCache = hlsInitSegmentCache;
|
||||
_hlsPlaylistFilter = hlsPlaylistFilter;
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
@@ -88,7 +94,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
public void Touch()
|
||||
public void Touch(Option<string> fileName)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
@@ -114,12 +120,16 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
foreach (string[] input in maybeLines)
|
||||
{
|
||||
await RefreshInits();
|
||||
|
||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(
|
||||
_outputFormat,
|
||||
_discontinuityMap,
|
||||
_outputFormatKind,
|
||||
PlaylistStart,
|
||||
filterBefore,
|
||||
GetAllInits(),
|
||||
input);
|
||||
_hlsInitSegmentCache,
|
||||
input,
|
||||
maybeMaxSegments: 10);
|
||||
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
|
||||
{
|
||||
DeleteOldSegments(trimResult);
|
||||
@@ -171,6 +181,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
try
|
||||
{
|
||||
_channelNumber = channelNumber;
|
||||
_workingDirectory = Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber);
|
||||
|
||||
foreach (TimeSpan timeout in idleTimeout)
|
||||
{
|
||||
@@ -183,17 +194,14 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
CancellationToken cancellationToken = _cancellationTokenSource.Token;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting HLS session for channel {Channel} with output format {OutputFormat}",
|
||||
channelNumber,
|
||||
_outputFormat);
|
||||
_logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
|
||||
|
||||
if (_localFileSystem.ListFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber)).Any())
|
||||
if (_localFileSystem.ListFiles(_workingDirectory).Any())
|
||||
{
|
||||
_logger.LogError("Transcode folder is NOT empty!");
|
||||
}
|
||||
|
||||
Touch();
|
||||
Touch(Option<string>.None);
|
||||
_transcodedUntil = DateTimeOffset.Now;
|
||||
PlaylistStart = _transcodedUntil;
|
||||
_channelStart = _transcodedUntil;
|
||||
@@ -261,7 +269,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
try
|
||||
{
|
||||
_localFileSystem.EmptyFolder(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber));
|
||||
_localFileSystem.EmptyFolder(_workingDirectory);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -297,7 +305,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
DateTimeOffset start = DateTimeOffset.Now;
|
||||
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");
|
||||
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
|
||||
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 NOT completing the item, seek again, transcode method will accelerate if needed
|
||||
HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndRealtime,
|
||||
|
||||
// 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 and zero and NOT completing the item, seek, transcode method will accelerate if needed
|
||||
HlsSessionState.ZeroAndWorkAhead when !isComplete => HlsSessionState.SeekAndRealtime,
|
||||
|
||||
// after starting at zero and completing the item, start at zero again, transcode method will throttle if needed
|
||||
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
|
||||
@@ -393,12 +401,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> Transcode(
|
||||
bool realtime,
|
||||
CancellationToken cancellationToken)
|
||||
private async Task<bool> Transcode(bool realtime, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool wasSeekAndWorkAhead = _state is HlsSessionState.SeekAndWorkAhead;
|
||||
|
||||
if (!realtime)
|
||||
{
|
||||
Interlocked.Increment(ref _workAheadCount);
|
||||
@@ -438,17 +446,16 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
long ptsOffset = await GetPtsOffset(_channelNumber, cancellationToken);
|
||||
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
|
||||
TimeSpan ptsOffset = await GetPtsOffset(_channelNumber, cancellationToken);
|
||||
|
||||
_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;
|
||||
|
||||
var request = new GetPlayoutItemProcessByChannelNumber(
|
||||
_channelNumber,
|
||||
_outputFormat is OutputFormatKind.HlsMp4 ? StreamingMode.HttpLiveStreamingSegmenterFmp4 : StreamingMode.HttpLiveStreamingSegmenter,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
now,
|
||||
startAtZero,
|
||||
realtime,
|
||||
@@ -476,6 +483,14 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
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;
|
||||
var stdErrBuffer = new StringBuilder();
|
||||
|
||||
@@ -502,6 +517,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
|
||||
CommandResult commandResult = await processWithPipe
|
||||
.WithWorkingDirectory(_workingDirectory)
|
||||
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(linkedCts.Token);
|
||||
@@ -509,7 +525,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
if (commandResult.ExitCode == 0)
|
||||
{
|
||||
_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;
|
||||
_state = NextState(_state, processModel);
|
||||
_hasWrittenSegments = true;
|
||||
@@ -535,7 +554,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
Either<BaseError, PlayoutItemProcessModel> maybeOfflineProcess = await _mediator.Send(
|
||||
new GetErrorProcess(
|
||||
_channelNumber,
|
||||
_outputFormat is OutputFormatKind.HlsMp4 ? StreamingMode.HttpLiveStreamingSegmenterFmp4 : StreamingMode.HttpLiveStreamingSegmenter,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
realtime,
|
||||
ptsOffset,
|
||||
processModel.MaybeDuration,
|
||||
@@ -630,12 +649,15 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
foreach (string[] lines in maybeLines)
|
||||
{
|
||||
await RefreshInits();
|
||||
|
||||
// trim playlist and insert discontinuity before appending with new ffmpeg process
|
||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
|
||||
_outputFormat,
|
||||
_discontinuityMap,
|
||||
_outputFormatKind,
|
||||
PlaylistStart,
|
||||
DateTimeOffset.Now.AddMinutes(-1),
|
||||
GetAllInits(),
|
||||
_hlsInitSegmentCache,
|
||||
lines);
|
||||
await WritePlaylist(trimResult.Playlist, cancellationToken);
|
||||
|
||||
@@ -652,10 +674,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
private void DeleteOldSegments(TrimPlaylistResult trimResult)
|
||||
{
|
||||
var generatedAtHash = new System.Collections.Generic.HashSet<long>();
|
||||
|
||||
// delete old segments
|
||||
var allSegments = Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "live*.ts")
|
||||
.Append(Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "live*.mp4"))
|
||||
.Append(Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "live*.m4s"))
|
||||
var allSegments = Directory.GetFiles(_workingDirectory, "live*.ts")
|
||||
.Append(Directory.GetFiles(_workingDirectory, "live*.mp4"))
|
||||
.Append(Directory.GetFiles(_workingDirectory, "live*.m4s"))
|
||||
.Map(file =>
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
@@ -668,18 +692,15 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
generatedAt = 0;
|
||||
}
|
||||
generatedAtHash.Add(generatedAt);
|
||||
return new Segment(file, sequenceNumber, generatedAt);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var allInits = Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "*init.mp4")
|
||||
.Map(file =>
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
return long.TryParse(fileName.Split('_')[0], out long generatedAt)
|
||||
var allInits = Directory.GetFiles(_workingDirectory, "*init.mp4")
|
||||
.Map(file => long.TryParse(Path.GetFileName(file).Split('_')[0], out long generatedAt) && !generatedAtHash.Contains(generatedAt)
|
||||
? new Segment(file, 0, generatedAt)
|
||||
: Option<Segment>.None;
|
||||
})
|
||||
: Option<Segment>.None)
|
||||
.Somes()
|
||||
.ToList();
|
||||
|
||||
@@ -693,16 +714,23 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
// trimResult.Sequence);
|
||||
}
|
||||
|
||||
if (allInits.Count > 0 && allSegments.Count > 0)
|
||||
foreach (var init in allInits)
|
||||
{
|
||||
long minKeep = allSegments.Except(toDelete).Map(s => s.GeneratedAt).Min();
|
||||
Option<Segment> maybeMinKeepInit =
|
||||
allInits.OrderByDescending(i => i.GeneratedAt).Find(i => i.GeneratedAt <= minKeep);
|
||||
foreach (var minKeepInit in maybeMinKeepInit)
|
||||
// only consider deleting inits that have no segments left on disk, no segments in ffmpeg playlist
|
||||
if (generatedAtHash.Contains(init.GeneratedAt) || init.GeneratedAt >= trimResult.GeneratedAt)
|
||||
{
|
||||
// _logger.LogDebug("Deleting HLS inits less than {GeneratedAt}", minKeepInit.GeneratedAt);
|
||||
toDelete.AddRange(allInits.Where(i => i.GeneratedAt < minKeepInit.GeneratedAt));
|
||||
continue;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -720,23 +748,29 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
private List<long> GetAllInits() =>
|
||||
_outputFormat is OutputFormatKind.HlsMp4
|
||||
? Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "*init.mp4")
|
||||
.Map(file =>
|
||||
private async Task RefreshInits()
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
return long.TryParse(fileName.Split('_')[0], out long generatedAt) ? generatedAt : long.MaxValue;
|
||||
})
|
||||
.ToList()
|
||||
: [];
|
||||
var allSegments = Directory.GetFiles(_workingDirectory, "live*.m4s")
|
||||
.Map(Path.GetFileName)
|
||||
.Map(s => s.Split("_")[1])
|
||||
.ToHashSet();
|
||||
|
||||
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);
|
||||
try
|
||||
{
|
||||
long result = 0;
|
||||
TimeSpan result = TimeSpan.Zero;
|
||||
|
||||
// if we haven't yet written any segments, start at zero
|
||||
if (!_hasWrittenSegments)
|
||||
@@ -744,8 +778,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
return result;
|
||||
}
|
||||
|
||||
await RefreshInits();
|
||||
|
||||
Either<BaseError, PtsTime> queryResult = await _mediator.Send(
|
||||
new GetLastPtsTime(channelNumber),
|
||||
new GetLastPtsTime(_hlsInitSegmentCache, channelNumber),
|
||||
cancellationToken);
|
||||
|
||||
foreach (BaseError error in queryResult.LeftToSeq())
|
||||
@@ -755,6 +791,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
foreach (PtsTime pts in queryResult.RightToSeq())
|
||||
{
|
||||
_logger.LogWarning("Last pts offset is {Pts}", pts.Value);
|
||||
result = pts.Value;
|
||||
}
|
||||
|
||||
@@ -788,10 +825,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
await File.WriteAllTextAsync(fileName, playlist, cancellationToken);
|
||||
}
|
||||
|
||||
private string PlaylistFileName() => Path.Combine(
|
||||
FileSystemLayout.TranscodeFolder,
|
||||
_channelNumber,
|
||||
"live.m3u8");
|
||||
private string PlaylistFileName() => Path.Combine(_workingDirectory, "live.m3u8");
|
||||
|
||||
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<TimeSpan> MaybeDuration,
|
||||
DateTimeOffset Until,
|
||||
bool IsComplete);
|
||||
bool IsComplete,
|
||||
Option<long> SegmentKey);
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
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)
|
||||
{
|
||||
string[] split = ffprobeLine.Split("|");
|
||||
var ptsTime = long.Parse(split[0], CultureInfo.InvariantCulture);
|
||||
if (long.TryParse(split[1], CultureInfo.InvariantCulture, out long duration))
|
||||
var ptsTime = double.Parse(split[0], CultureInfo.InvariantCulture);
|
||||
if (double.TryParse(split[1], CultureInfo.InvariantCulture, out double duration))
|
||||
{
|
||||
ptsTime += duration;
|
||||
}
|
||||
|
||||
return new PtsTime(ptsTime);
|
||||
return new PtsTime(TimeSpan.FromSeconds(ptsTime));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,4 @@ public record FFmpegProcessRequest(
|
||||
bool StartAtZero,
|
||||
bool HlsRealtime,
|
||||
DateTimeOffset ChannelStartTime,
|
||||
long PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
|
||||
TimeSpan PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
|
||||
|
||||
@@ -11,7 +11,7 @@ public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
|
||||
false,
|
||||
true,
|
||||
DateTimeOffset.Now, // unused
|
||||
0)
|
||||
TimeSpan.Zero)
|
||||
{
|
||||
Scheme = scheme;
|
||||
Host = host;
|
||||
|
||||
@@ -43,6 +43,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
|
||||
Option<GraphicsEngineContext>.None,
|
||||
Option<TimeSpan>.None,
|
||||
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; }
|
||||
}
|
||||
-43
@@ -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,
|
||||
StreamingMode Mode,
|
||||
bool HlsRealtime,
|
||||
long PtsOffset,
|
||||
TimeSpan PtsOffset,
|
||||
Option<TimeSpan> MaybeDuration,
|
||||
DateTimeOffset Until,
|
||||
string ErrorMessage) : FFmpegProcessRequest(
|
||||
|
||||
@@ -8,16 +8,11 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
|
||||
{
|
||||
private readonly IFFmpegProcessService _ffmpegProcessService;
|
||||
|
||||
public GetErrorProcessHandler(
|
||||
public class GetErrorProcessHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegProcessService ffmpegProcessService)
|
||||
: base(dbContextFactory) =>
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
|
||||
: FFmpegProcessHandler<GetErrorProcess>(dbContextFactory)
|
||||
{
|
||||
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
||||
TvContext dbContext,
|
||||
GetErrorProcess request,
|
||||
@@ -26,9 +21,12 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Command process = await _ffmpegProcessService.ForError(
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
|
||||
Command process = await ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
now,
|
||||
request.MaybeDuration,
|
||||
request.ErrorMessage,
|
||||
request.HlsRealtime,
|
||||
@@ -43,6 +41,7 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
|
||||
Option<GraphicsEngineContext>.None,
|
||||
request.MaybeDuration,
|
||||
request.Until,
|
||||
true);
|
||||
true,
|
||||
now.ToUnixTimeSeconds());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,18 @@ public class GetHlsPlaylistByChannelNumberHandler :
|
||||
Parameters parameters,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
string mode = string.IsNullOrWhiteSpace(request.Mode)
|
||||
? string.Empty
|
||||
: $"&mode={request.Mode}";
|
||||
string mode = request.Mode switch
|
||||
{
|
||||
"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);
|
||||
double timeRemaining = Math.Abs((parameters.PlayoutItem.FinishOffset - now).TotalSeconds);
|
||||
@@ -49,7 +58,7 @@ public class GetHlsPlaylistByChannelNumberHandler :
|
||||
#EXT-X-MEDIA-SEQUENCE:{index}
|
||||
#EXT-X-DISCONTINUITY
|
||||
#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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
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;
|
||||
|
||||
public class GetLastPtsTimeHandler : 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(
|
||||
public class GetLastPtsTimeHandler(
|
||||
IClient client,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ITempFilePool tempFilePool,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<GetLastPtsTimeHandler> logger)
|
||||
: IRequestHandler<GetLastPtsTime, Either<BaseError, PtsTime>>
|
||||
{
|
||||
_client = client;
|
||||
_localFileSystem = localFileSystem;
|
||||
_tempFilePool = tempFilePool;
|
||||
_configElementRepository = configElementRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PtsTime>> Handle(
|
||||
GetLastPtsTime request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -48,13 +34,13 @@ public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<Base
|
||||
GetLastPtsTime request,
|
||||
CancellationToken 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(
|
||||
RequestParameters parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<FileInfo> maybeLastSegment = GetLastSegment(parameters.ChannelNumber);
|
||||
Option<FileInfo> maybeLastSegment = GetLastSegment(parameters);
|
||||
foreach (FileInfo segment in maybeLastSegment)
|
||||
{
|
||||
return await GetPts(parameters, segment, cancellationToken).IfNoneAsync(PtsTime.Zero);
|
||||
@@ -72,20 +58,33 @@ public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<Base
|
||||
{
|
||||
"-v", "0",
|
||||
"-show_entries",
|
||||
"packet=pts,duration",
|
||||
"packet=pts_time,duration_time",
|
||||
"-of", "compact=p=0:nk=1",
|
||||
segment.FullName
|
||||
};
|
||||
|
||||
string lastLine = string.Empty;
|
||||
PtsTime maxTime = PtsTime.Zero;
|
||||
Action<string> replaceLine = 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)
|
||||
.WithArguments(argumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
@@ -97,27 +96,66 @@ public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<Base
|
||||
return Option<PtsTime>.None;
|
||||
}
|
||||
|
||||
return maxTime;
|
||||
}
|
||||
|
||||
private Option<FileInfo> GetLastSegment(RequestParameters parameters)
|
||||
{
|
||||
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, parameters.ChannelNumber));
|
||||
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
|
||||
{
|
||||
return PtsTime.From(lastLine);
|
||||
}
|
||||
catch (Exception ex)
|
||||
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))
|
||||
{
|
||||
_client.Notify(ex);
|
||||
await SaveTroubleshootingData(parameters.ChannelNumber, lastLine);
|
||||
// copy init
|
||||
using (var readInit = File.OpenRead(fullInit))
|
||||
{
|
||||
readInit.CopyTo(output);
|
||||
}
|
||||
|
||||
return Option<PtsTime>.None;
|
||||
// copy segment
|
||||
using (var readSegment = File.OpenRead(lastSegment.FullName))
|
||||
{
|
||||
readSegment.CopyTo(output);
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<FileInfo> GetLastSegment(string channelNumber)
|
||||
// return concatenated init + segment
|
||||
return new FileInfo(combined);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
|
||||
return Optional(directory.GetFiles("*.ts").OrderByDescending(f => f.Name).FirstOrDefault());
|
||||
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) =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
|
||||
configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
|
||||
.FilterT(File.Exists)
|
||||
.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 playlistContents = string.Empty;
|
||||
if (_localFileSystem.FileExists(playlistFileName))
|
||||
if (localFileSystem.FileExists(playlistFileName))
|
||||
{
|
||||
playlistContents = await File.ReadAllTextAsync(playlistFileName);
|
||||
}
|
||||
@@ -138,18 +176,21 @@ public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<Base
|
||||
var data = new TroubleshootingData(allFiles, playlistContents, output);
|
||||
string serialized = data.Serialize();
|
||||
|
||||
string file = _tempFilePool.GetNextTempFile(TempFileCategory.BadTranscodeFolder);
|
||||
string file = tempFilePool.GetNextTempFile(TempFileCategory.BadTranscodeFolder);
|
||||
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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ public record GetPlayoutItemProcessByChannelNumber(
|
||||
bool StartAtZero,
|
||||
bool HlsRealtime,
|
||||
DateTimeOffset ChannelStart,
|
||||
long PtsOffset,
|
||||
TimeSpan PtsOffset,
|
||||
Option<int> TargetFramerate) : FFmpegProcessRequest(
|
||||
ChannelNumber,
|
||||
Mode,
|
||||
|
||||
+31
-6
@@ -281,11 +281,28 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
DateTimeOffset effectiveNow = request.StartAtZero ? start : now;
|
||||
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)
|
||||
{
|
||||
Command doesNotExistProcess = await _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
now,
|
||||
duration,
|
||||
$"DEBUG_NO_SYNC:\n{Mapper.GetDisplayTitle(playoutItemWithPath.PlayoutItem.MediaItem, Option<string>.None)}\nFrom: {start} To: {finish}",
|
||||
request.HlsRealtime,
|
||||
@@ -300,7 +317,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Option<GraphicsEngineContext>.None,
|
||||
duration,
|
||||
finish,
|
||||
true);
|
||||
true,
|
||||
now.ToUnixTimeSeconds());
|
||||
}
|
||||
|
||||
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
|
||||
@@ -414,12 +432,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
: StreamInputKind.Vod,
|
||||
playoutItemWithPath.PlayoutItem.FillerKind,
|
||||
inPoint,
|
||||
outPoint,
|
||||
request.ChannelStartTime,
|
||||
request.PtsOffset,
|
||||
request.TargetFramerate,
|
||||
Option<string>.None,
|
||||
_ => { },
|
||||
canProxy: true,
|
||||
cancellationToken);
|
||||
|
||||
var result = new PlayoutItemProcessModel(
|
||||
@@ -427,7 +445,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
playoutItemResult.GraphicsEngineContext,
|
||||
duration,
|
||||
finish,
|
||||
true);
|
||||
isComplete,
|
||||
effectiveNow.ToUnixTimeSeconds());
|
||||
|
||||
return Right<BaseError, PlayoutItemProcessModel>(result);
|
||||
}
|
||||
@@ -458,6 +477,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Command offlineProcess = await _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
now,
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime,
|
||||
@@ -472,11 +492,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Option<GraphicsEngineContext>.None,
|
||||
maybeDuration,
|
||||
finish,
|
||||
true);
|
||||
true,
|
||||
now.ToUnixTimeSeconds());
|
||||
case PlayoutItemDoesNotExistOnDisk:
|
||||
Command doesNotExistProcess = await _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
now,
|
||||
maybeDuration,
|
||||
error.Value,
|
||||
request.HlsRealtime,
|
||||
@@ -491,11 +513,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Option<GraphicsEngineContext>.None,
|
||||
maybeDuration,
|
||||
finish,
|
||||
true);
|
||||
true,
|
||||
now.ToUnixTimeSeconds());
|
||||
default:
|
||||
Command errorProcess = await _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
now,
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime,
|
||||
@@ -510,7 +534,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Option<GraphicsEngineContext>.None,
|
||||
maybeDuration,
|
||||
finish,
|
||||
true);
|
||||
true,
|
||||
now.ToUnixTimeSeconds());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
|
||||
false,
|
||||
true,
|
||||
DateTimeOffset.Now, // unused
|
||||
0)
|
||||
TimeSpan.Zero)
|
||||
{
|
||||
Scheme = scheme;
|
||||
Host = host;
|
||||
|
||||
@@ -44,6 +44,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
|
||||
Option<GraphicsEngineContext>.None,
|
||||
Option<TimeSpan>.None,
|
||||
DateTimeOffset.MaxValue,
|
||||
true);
|
||||
true,
|
||||
Option<long>.None);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -67,7 +67,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
catch (Exception ex)
|
||||
{
|
||||
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");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
@@ -246,12 +246,12 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
mediaItem is RemoteStream { IsLive: true } ? StreamInputKind.Live : StreamInputKind.Vod,
|
||||
FillerKind.None,
|
||||
inPoint,
|
||||
outPoint,
|
||||
channelStartTime: DateTimeOffset.Now,
|
||||
0,
|
||||
None,
|
||||
TimeSpan.Zero,
|
||||
Option<int>.None,
|
||||
FileSystemLayout.TranscodeTroubleshootingFolder,
|
||||
_ => { },
|
||||
canProxy: true,
|
||||
cancellationToken);
|
||||
|
||||
return playoutItemResult;
|
||||
@@ -293,6 +293,10 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
return [subtitle];
|
||||
}
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(request.StreamSelector))
|
||||
{
|
||||
allSubtitles.Clear();
|
||||
}
|
||||
|
||||
return allSubtitles;
|
||||
}
|
||||
|
||||
+25
-2
@@ -2,6 +2,7 @@ using System.IO.Pipelines;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -16,7 +17,7 @@ using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Troubleshooting;
|
||||
|
||||
public class StartTroubleshootingPlaybackHandler(
|
||||
public partial class StartTroubleshootingPlaybackHandler(
|
||||
ITroubleshootingNotifier notifier,
|
||||
IMediator mediator,
|
||||
IEntityLocker entityLocker,
|
||||
@@ -134,6 +135,7 @@ public class StartTroubleshootingPlaybackHandler(
|
||||
}
|
||||
|
||||
CommandResult commandResult = await processWithPipe
|
||||
.WithWorkingDirectory(FileSystemLayout.TranscodeTroubleshootingFolder)
|
||||
.WithStandardErrorPipe(PipeTarget.Null)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(linkedCts.Token);
|
||||
@@ -152,8 +154,26 @@ public class StartTroubleshootingPlaybackHandler(
|
||||
// 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(
|
||||
new PlaybackTroubleshootingCompletedNotification(commandResult.ExitCode),
|
||||
new PlaybackTroubleshootingCompletedNotification(
|
||||
commandResult.ExitCode,
|
||||
Option<Exception>.None,
|
||||
maybeSpeed),
|
||||
linkedCts.Token);
|
||||
|
||||
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode);
|
||||
@@ -186,4 +206,7 @@ public class StartTroubleshootingPlaybackHandler(
|
||||
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="CliWrap" Version="3.9.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -22,7 +22,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="NSubstitute" Version="5.3.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.Extensions.Logging" Version="9.0.2" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
|
||||
@@ -29,7 +29,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -43,14 +42,13 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegProfile ffmpegProfile = TestProfile();
|
||||
|
||||
FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreamingSegmenterFmp4,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
ffmpegProfile,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -75,7 +73,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -96,7 +93,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -110,14 +106,13 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 };
|
||||
|
||||
FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreamingSegmenterFmp4,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
ffmpegProfile,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -138,7 +133,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -161,7 +155,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -184,7 +177,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -205,7 +197,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -228,7 +219,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
now,
|
||||
now.AddMinutes(5),
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -252,7 +242,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
now,
|
||||
now.AddMinutes(5),
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -280,7 +269,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -307,7 +295,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -334,7 +321,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -362,7 +348,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -389,7 +374,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -419,7 +403,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -450,7 +433,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -480,7 +462,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -510,7 +491,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -540,7 +520,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -572,7 +551,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -603,7 +581,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -634,7 +611,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -664,7 +640,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -695,7 +670,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -725,7 +699,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -756,7 +729,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -782,7 +754,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -806,7 +777,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -830,7 +800,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -855,7 +824,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -880,7 +848,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -905,7 +872,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -930,7 +896,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -954,7 +919,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -978,7 +942,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -987,7 +950,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioDuration_With_CorrectFormat_ForTransportStream()
|
||||
public void Should_SetPadAudio_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
@@ -1008,12 +971,11 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMinutes(2),
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
|
||||
actual.AudioDuration.IfNone(TimeSpan.MinValue).ShouldBe(TimeSpan.FromMinutes(2));
|
||||
actual.PadAudio.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -1032,7 +994,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
@@ -1058,7 +1019,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
StreamInputKind.Vod,
|
||||
None);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,8 @@ public class FakeLocalFileSystem : ILocalFileSystem
|
||||
.IfNoneAsync(string.Empty)
|
||||
.Map(s => s.Split(Environment.NewLine));
|
||||
|
||||
public Task<byte[]> GetHash(string path) => throw new NotSupportedException();
|
||||
|
||||
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
|
||||
|
||||
private static List<DirectoryInfo> Split(DirectoryInfo path)
|
||||
|
||||
@@ -484,7 +484,163 @@ public class BlockPlayoutBuilderTests
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
new PlayoutItem(),
|
||||
new List<MediaChapter>(),
|
||||
true,
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.Count.ShouldBe(1);
|
||||
@@ -112,7 +112,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
new PlayoutItem(),
|
||||
new List<MediaChapter> { new() },
|
||||
true,
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.Count.ShouldBe(1);
|
||||
@@ -179,7 +179,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) }
|
||||
},
|
||||
true,
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.Count.ShouldBe(3);
|
||||
@@ -271,7 +271,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
|
||||
},
|
||||
true,
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
|
||||
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(30), EndTime = TimeSpan.FromMinutes(45) }
|
||||
],
|
||||
true,
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
|
||||
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(30), EndTime = TimeSpan.FromMinutes(45) }
|
||||
],
|
||||
true,
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.Count.ShouldBe(6);
|
||||
@@ -598,7 +598,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
|
||||
},
|
||||
true,
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
|
||||
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(30), EndTime = TimeSpan.FromMinutes(45) }
|
||||
],
|
||||
true,
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.Count.ShouldBe(5);
|
||||
@@ -770,15 +770,13 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
MovieMetadata = new List<MovieMetadata> { new() { ReleaseDate = aired } },
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new() { Duration = duration }
|
||||
}
|
||||
MovieMetadata = [new MovieMetadata { ReleaseDate = aired }],
|
||||
MediaVersions = [new MediaVersion { Duration = duration }]
|
||||
};
|
||||
|
||||
|
||||
private class TestScheduler : PlayoutModeSchedulerBase<ProgramScheduleItem>
|
||||
private class TestScheduler()
|
||||
: PlayoutModeSchedulerBase<ProgramScheduleItem>(LoggerFactory.CreateLogger<TestScheduler>())
|
||||
{
|
||||
private static readonly ILoggerFactory LoggerFactory;
|
||||
|
||||
@@ -792,11 +790,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
LoggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
|
||||
}
|
||||
|
||||
public TestScheduler() : base(LoggerFactory.CreateLogger<TestScheduler>())
|
||||
{
|
||||
}
|
||||
|
||||
public override Tuple<PlayoutBuilderState, List<PlayoutItem>> Schedule(
|
||||
public override PlayoutSchedulerResult Schedule(
|
||||
PlayoutBuilderState playoutBuilderState,
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
|
||||
ProgramScheduleItem scheduleItem,
|
||||
|
||||
@@ -60,7 +60,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(_logger);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -133,7 +133,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(_logger);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -205,7 +205,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(_logger);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -274,7 +274,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(_logger);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -357,7 +357,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(_logger);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -444,7 +444,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(_logger);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -543,7 +543,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(_logger);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -653,7 +653,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(_logger);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -802,7 +802,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(_logger);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -874,7 +874,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(_logger);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
|
||||
@@ -51,7 +51,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -126,7 +126,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -223,7 +223,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -308,7 +308,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.PostRollFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -396,7 +396,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -478,7 +478,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -576,7 +576,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -664,7 +664,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -772,7 +772,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -886,7 +886,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
// hard stop at 2, an hour before the "next schedule item" at 3
|
||||
DateTimeOffset hardStop = StartState(scheduleItemsEnumerator).CurrentTime.AddHours(2);
|
||||
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -992,7 +992,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
// hard stop at 2, an hour before the "next schedule item" at 3
|
||||
DateTimeOffset hardStop = StartState(scheduleItemsEnumerator).CurrentTime.AddHours(2);
|
||||
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -1104,7 +1104,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -1184,7 +1184,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
|
||||
@@ -53,7 +53,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -155,7 +155,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -226,7 +226,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -301,7 +301,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -384,7 +384,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -483,7 +483,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -572,7 +572,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -682,7 +682,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -804,7 +804,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -890,7 +890,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
|
||||
@@ -45,7 +45,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
@@ -122,7 +122,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -196,7 +196,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -278,7 +278,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -350,7 +350,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
|
||||
scheduleItem,
|
||||
@@ -442,7 +442,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -546,7 +546,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -632,7 +632,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -732,7 +732,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
@@ -817,7 +817,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerOne(Substitute.For<ILogger>());
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings _) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user