diff --git a/CHANGELOG.md b/CHANGELOG.md index 718b207c3..fda02b1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Add `Multi Collection` to support shuffling multiple collections within a single schedule item - Collections within a multi collection are optionally grouped together and ordered when scheduling; this can be useful for franchises +- Add `Playout Days To Build` setting to control how many days of playout data/program guide data should be built into the future ### Changed - Move `Playback Order` from schedule to schedule items diff --git a/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuild.cs b/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuild.cs new file mode 100644 index 000000000..fba7759e5 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuild.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.Configuration.Commands +{ + public record UpdatePlayoutDaysToBuild(int DaysToBuild) : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuildHandler.cs b/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuildHandler.cs new file mode 100644 index 000000000..f69631bf2 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuildHandler.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application.Playouts.Commands; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Configuration.Commands +{ + public class + UpdatePlayoutDaysToBuildHandler : MediatR.IRequestHandler> + { + private readonly IConfigElementRepository _configElementRepository; + private readonly IDbContextFactory _dbContextFactory; + private readonly ChannelWriter _workerChannel; + + public UpdatePlayoutDaysToBuildHandler( + IConfigElementRepository configElementRepository, + IDbContextFactory dbContextFactory, + ChannelWriter workerChannel) + { + _configElementRepository = configElementRepository; + _dbContextFactory = dbContextFactory; + _workerChannel = workerChannel; + } + + public async Task> Handle( + UpdatePlayoutDaysToBuild request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Validation validation = await Validate(request); + return await validation.Apply(_ => ApplyUpdate(dbContext, request.DaysToBuild)); + } + + private async Task ApplyUpdate(TvContext dbContext, int daysToBuild) + { + await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild); + + // build all playouts to proper number of days + List playouts = await dbContext.Playouts + .Include(p => p.Channel) + .ToListAsync(); + foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id)) + { + await _workerChannel.WriteAsync(new BuildPlayout(playoutId)); + } + + return Unit.Default; + } + + private static Task> Validate(UpdatePlayoutDaysToBuild request) => + Optional(request.DaysToBuild) + .Filter(days => days > 0) + .Map(_ => Unit.Default) + .ToValidation("Days to build must be greater than zero") + .AsTask(); + } +} diff --git a/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuild.cs b/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuild.cs new file mode 100644 index 000000000..b17e1609f --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuild.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace ErsatzTV.Application.Configuration.Queries +{ + public record GetPlayoutDaysToBuild : IRequest; +} diff --git a/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuildHandler.cs b/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuildHandler.cs new file mode 100644 index 000000000..a328abcf1 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuildHandler.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Configuration.Queries +{ + public class GetPlayoutDaysToBuildHandler : IRequestHandler + { + private readonly IConfigElementRepository _configElementRepository; + + public GetPlayoutDaysToBuildHandler(IConfigElementRepository configElementRepository) => + _configElementRepository = configElementRepository; + + public Task Handle(GetPlayoutDaysToBuild request, CancellationToken cancellationToken) => + _configElementRepository.GetValue(ConfigElementKey.PlayoutDaysToBuild) + .Map(result => result.IfNone(2)); + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs index d0f983f98..fcd677dec 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs @@ -351,9 +351,15 @@ namespace ErsatzTV.Core.Tests.Scheduling Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } }; + var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); - var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger); + var builder = new PlayoutBuilder( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(6); @@ -433,9 +439,15 @@ namespace ErsatzTV.Core.Tests.Scheduling Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } }; + var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); - var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger); + var builder = new PlayoutBuilder( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(7); @@ -519,9 +531,15 @@ namespace ErsatzTV.Core.Tests.Scheduling Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } }; + var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); - var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger); + var builder = new PlayoutBuilder( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(24); @@ -611,9 +629,15 @@ namespace ErsatzTV.Core.Tests.Scheduling } }; + var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); - var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger); + var builder = new PlayoutBuilder( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(32); @@ -699,9 +723,15 @@ namespace ErsatzTV.Core.Tests.Scheduling Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } }; + var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); - var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger); + var builder = new PlayoutBuilder( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(6); @@ -791,9 +821,15 @@ namespace ErsatzTV.Core.Tests.Scheduling Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } }; + var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); - var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger); + var builder = new PlayoutBuilder( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(6); @@ -887,9 +923,15 @@ namespace ErsatzTV.Core.Tests.Scheduling } }; + var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); - var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger); + var builder = new PlayoutBuilder( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(5); @@ -976,9 +1018,15 @@ namespace ErsatzTV.Core.Tests.Scheduling Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, }; + var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); - var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger); + var builder = new PlayoutBuilder( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(5); @@ -1073,9 +1121,15 @@ namespace ErsatzTV.Core.Tests.Scheduling } }; + var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); - var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger); + var builder = new PlayoutBuilder( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(5); @@ -1133,10 +1187,16 @@ namespace ErsatzTV.Core.Tests.Scheduling MediaItems = mediaItems }; + var configRepo = new Mock(); var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems))); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); - var builder = new PlayoutBuilder(collectionRepo, televisionRepo, artistRepo.Object, _logger); + var builder = new PlayoutBuilder( + configRepo.Object, + collectionRepo, + televisionRepo, + artistRepo.Object, + _logger); var items = new List { Flood(mediaCollection, playbackOrder) }; diff --git a/ErsatzTV.Core/Domain/ConfigElementKey.cs b/ErsatzTV.Core/Domain/ConfigElementKey.cs index ce71039ba..357e28ffe 100644 --- a/ErsatzTV.Core/Domain/ConfigElementKey.cs +++ b/ErsatzTV.Core/Domain/ConfigElementKey.cs @@ -24,5 +24,6 @@ public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size"); public static ConfigElementKey LogsPageSize => new("pages.logs.page_size"); public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval"); + public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build"); } } diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index a502364a1..98f6fd105 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -20,25 +20,29 @@ namespace ErsatzTV.Core.Scheduling private static readonly Random Random = new(); private readonly IArtistRepository _artistRepository; private readonly ILogger _logger; + private readonly IConfigElementRepository _configElementRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly ITelevisionRepository _televisionRepository; public PlayoutBuilder( + IConfigElementRepository configElementRepository, IMediaCollectionRepository mediaCollectionRepository, ITelevisionRepository televisionRepository, IArtistRepository artistRepository, ILogger logger) { + _configElementRepository = configElementRepository; _mediaCollectionRepository = mediaCollectionRepository; _televisionRepository = televisionRepository; _artistRepository = artistRepository; _logger = logger; } - public Task BuildPlayoutItems(Playout playout, bool rebuild = false) + public async Task BuildPlayoutItems(Playout playout, bool rebuild = false) { DateTimeOffset now = DateTimeOffset.Now; - return BuildPlayoutItems(playout, now, now.AddDays(2), rebuild); + Option daysToBuild = await _configElementRepository.GetValue(ConfigElementKey.PlayoutDaysToBuild); + return await BuildPlayoutItems(playout, now, now.AddDays(await daysToBuild.IfNoneAsync(2)), rebuild); } public async Task BuildPlayoutItems( @@ -392,15 +396,6 @@ namespace ErsatzTV.Core.Scheduling // once more to get playout anchor ProgramScheduleItem nextScheduleItem = sortedScheduleItems[index % sortedScheduleItems.Count]; - playout.Anchor = new PlayoutAnchor - { - NextScheduleItem = nextScheduleItem, - NextScheduleItemId = nextScheduleItem.Id, - NextStart = GetStartTimeAfter(nextScheduleItem, currentTime).UtcDateTime, - MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null, - DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null, - InFlood = inFlood - }; // build program schedule anchors playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators); @@ -408,6 +403,19 @@ namespace ErsatzTV.Core.Scheduling // remove any items outside the desired range playout.Items.RemoveAll(old => old.FinishOffset < playoutStart || old.StartOffset > playoutFinish); + DateTimeOffset maxStartTime = playout.Items.Max(i => i.FinishOffset); + DateTimeOffset minCurrentTime = maxStartTime < currentTime ? maxStartTime : currentTime; + + playout.Anchor = new PlayoutAnchor + { + NextScheduleItem = nextScheduleItem, + NextScheduleItemId = nextScheduleItem.Id, + NextStart = GetStartTimeAfter(nextScheduleItem, minCurrentTime).UtcDateTime, + MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null, + DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null, + InFlood = inFlood + }; + return playout; } diff --git a/ErsatzTV/Pages/Settings.razor b/ErsatzTV/Pages/Settings.razor index 885b15170..38dd9732d 100644 --- a/ErsatzTV/Pages/Settings.razor +++ b/ErsatzTV/Pages/Settings.razor @@ -17,100 +17,126 @@ @inject ILogger _logger - - - - FFmpeg Settings - - - - - - - - - - - @foreach (FFmpegProfileViewModel profile in _ffmpegProfiles) +
+ + + + FFmpeg Settings + + + + + + + + + + + @foreach (FFmpegProfileViewModel profile in _ffmpegProfiles) + { + @profile.Name + } + + + + @foreach (CultureInfo culture in _availableCultures) { - @profile.Name + @culture.EnglishName } - - - @foreach (CultureInfo culture in _availableCultures) - { - @culture.EnglishName - } - - - - - - (none) - @foreach (WatermarkViewModel watermark in _watermarks) - { - @watermark.Name - } - - - - - Save Settings - - - - - - HDHomeRun Settings - - - - - - - - - Save Settings - - - - - - Scanner Settings - - - - - - - - - Save Settings - - + + + + + (none) + @foreach (WatermarkViewModel watermark in _watermarks) + { + @watermark.Name + } + + + + + Save Settings + + + + + + HDHomeRun Settings + + + + + + + + + Save Settings + + + + + + Scanner Settings + + + + + + + + + Save Settings + + + + + + Playout Settings + + + + + + + + + Save Settings + + +
@code { private bool _success; private bool _hdhrSuccess; private bool _scannerSuccess; + private bool _playoutSuccess; private List _ffmpegProfiles; private FFmpegSettingsViewModel _ffmpegSettings; private List _availableCultures; private List _watermarks; private int _tunerCount; private int _libraryRefreshInterval; + private int _playoutDaysToBuild; protected override async Task OnParametersSetAsync() { @@ -124,6 +150,8 @@ _hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount)); _libraryRefreshInterval = await _mediator.Send(new GetLibraryRefreshInterval()); _scannerSuccess = _libraryRefreshInterval > 0; + _playoutDaysToBuild = await _mediator.Send(new GetPlayoutDaysToBuild()); + _playoutSuccess = _playoutDaysToBuild > 0; } private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null; @@ -131,6 +159,8 @@ private static string ValidateTunerCount(int tunerCount) => tunerCount <= 0 ? "Tuner count must be greater than zero" : null; private static string ValidateLibraryRefreshInterval(int libraryRefreshInterval) => libraryRefreshInterval <= 0 ? "Library refresh interval must be greater than zero" : null; + + private static string ValidatePlayoutDaysToBuild(int daysToBuild) => daysToBuild <= 0 ? "Days to build must be greater than zero" : null; private async Task LoadFFmpegProfilesAsync() => _ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles()); @@ -171,4 +201,16 @@ Right: _ => _snackbar.Add("Successfully saved scanner settings", Severity.Success)); } + private async Task SavePlayoutSettings() + { + Either result = await _mediator.Send(new UpdatePlayoutDaysToBuild(_playoutDaysToBuild)); + result.Match( + Left: error => + { + _snackbar.Add(error.Value, Severity.Error); + _logger.LogError("Unexpected error saving playout settings: {Error}", error.Value); + }, + Right: _ => _snackbar.Add("Successfully saved playout settings", Severity.Success)); + } + } \ No newline at end of file