From 59c793b9be3f5d4ca20c495c6491055f5e8852e0 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Mon, 9 May 2022 09:21:51 -0500 Subject: [PATCH] add option to skip missing items in playouts (#795) --- CHANGELOG.md | 1 + .../Commands/UpdatePlayoutDaysToBuild.cs | 5 - .../Commands/UpdatePlayoutSettings.cs | 5 + ...ler.cs => UpdatePlayoutSettingsHandler.cs} | 21 +- .../Configuration/PlayoutSettingsViewModel.cs | 7 + .../Queries/GetPlayoutDaysToBuild.cs | 3 - .../Queries/GetPlayoutDaysToBuildHandler.cs | 16 -- .../Queries/GetPlayoutSettings.cs | 3 + .../Queries/GetPlayoutSettingsHandler.cs | 26 +++ .../Scheduling/PlayoutBuilderTests.cs | 190 +++++++++++++++++- ErsatzTV.Core/Domain/ConfigElementKey.cs | 1 + ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 39 ++-- ErsatzTV/Pages/Settings.razor | 19 +- 13 files changed, 278 insertions(+), 58 deletions(-) delete mode 100644 ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuild.cs create mode 100644 ErsatzTV.Application/Configuration/Commands/UpdatePlayoutSettings.cs rename ErsatzTV.Application/Configuration/Commands/{UpdatePlayoutDaysToBuildHandler.cs => UpdatePlayoutSettingsHandler.cs} (72%) create mode 100644 ErsatzTV.Application/Configuration/PlayoutSettingsViewModel.cs delete mode 100644 ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuild.cs delete mode 100644 ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuildHandler.cs create mode 100644 ErsatzTV.Application/Configuration/Queries/GetPlayoutSettings.cs create mode 100644 ErsatzTV.Application/Configuration/Queries/GetPlayoutSettingsHandler.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e71bca..760e21b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `metadata_kind` field to search index to allow searching for items with a particular metdata source - Valid metadata kinds are `fallback`, `sidecar` (NFO), `external` (from a media server) and `embedded` (songs) - Add autocomplete functionality to search bar to quickly navigate to channels, ffmpeg profiles, collections and schedules by name +- Add global setting to skip missing (file-not-found or unavailable) items when building playouts ### Changed - Replace invalid (control) characters in NFO metadata with replacement character `�` before parsing diff --git a/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuild.cs b/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuild.cs deleted file mode 100644 index 9fb50ef8..00000000 --- a/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuild.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ErsatzTV.Core; - -namespace ErsatzTV.Application.Configuration; - -public record UpdatePlayoutDaysToBuild(int DaysToBuild) : IRequest>; diff --git a/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutSettings.cs b/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutSettings.cs new file mode 100644 index 00000000..7773f073 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutSettings.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Configuration; + +public record UpdatePlayoutSettings(PlayoutSettingsViewModel PlayoutSettings) : IRequest>; diff --git a/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuildHandler.cs b/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutSettingsHandler.cs similarity index 72% rename from ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuildHandler.cs rename to ErsatzTV.Application/Configuration/Commands/UpdatePlayoutSettingsHandler.cs index a5c78beb..c4b77008 100644 --- a/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuildHandler.cs +++ b/ErsatzTV.Application/Configuration/Commands/UpdatePlayoutSettingsHandler.cs @@ -9,13 +9,13 @@ using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Configuration; -public class UpdatePlayoutDaysToBuildHandler : IRequestHandler> +public class UpdatePlayoutSettingsHandler : IRequestHandler> { private readonly IConfigElementRepository _configElementRepository; private readonly IDbContextFactory _dbContextFactory; private readonly ChannelWriter _workerChannel; - public UpdatePlayoutDaysToBuildHandler( + public UpdatePlayoutSettingsHandler( IConfigElementRepository configElementRepository, IDbContextFactory dbContextFactory, ChannelWriter workerChannel) @@ -26,17 +26,20 @@ public class UpdatePlayoutDaysToBuildHandler : IRequestHandler> Handle( - UpdatePlayoutDaysToBuild request, + UpdatePlayoutSettings request, CancellationToken cancellationToken) { - await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); Validation validation = await Validate(request); - return await validation.Apply(_ => ApplyUpdate(dbContext, request.DaysToBuild)); + return await validation.Apply(_ => ApplyUpdate(dbContext, request.PlayoutSettings)); } - private async Task ApplyUpdate(TvContext dbContext, int daysToBuild) + private async Task ApplyUpdate(TvContext dbContext, PlayoutSettingsViewModel playoutSettings) { - await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild); + await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, playoutSettings.DaysToBuild); + await _configElementRepository.Upsert( + ConfigElementKey.PlayoutSkipMissingItems, + playoutSettings.SkipMissingItems); // continue all playouts to proper number of days List playouts = await dbContext.Playouts @@ -50,8 +53,8 @@ public class UpdatePlayoutDaysToBuildHandler : IRequestHandler> Validate(UpdatePlayoutDaysToBuild request) => - Optional(request.DaysToBuild) + private static Task> Validate(UpdatePlayoutSettings request) => + Optional(request.PlayoutSettings.DaysToBuild) .Where(days => days > 0) .Map(_ => Unit.Default) .ToValidation("Days to build must be greater than zero") diff --git a/ErsatzTV.Application/Configuration/PlayoutSettingsViewModel.cs b/ErsatzTV.Application/Configuration/PlayoutSettingsViewModel.cs new file mode 100644 index 00000000..beb21c01 --- /dev/null +++ b/ErsatzTV.Application/Configuration/PlayoutSettingsViewModel.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Application.Configuration; + +public class PlayoutSettingsViewModel +{ + public int DaysToBuild { get; set; } + public bool SkipMissingItems { get; set; } +} diff --git a/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuild.cs b/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuild.cs deleted file mode 100644 index b9653768..00000000 --- a/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuild.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace ErsatzTV.Application.Configuration; - -public record GetPlayoutDaysToBuild : IRequest; diff --git a/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuildHandler.cs b/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuildHandler.cs deleted file mode 100644 index 5a78ebd8..00000000 --- a/ErsatzTV.Application/Configuration/Queries/GetPlayoutDaysToBuildHandler.cs +++ /dev/null @@ -1,16 +0,0 @@ -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Repositories; - -namespace ErsatzTV.Application.Configuration; - -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.Application/Configuration/Queries/GetPlayoutSettings.cs b/ErsatzTV.Application/Configuration/Queries/GetPlayoutSettings.cs new file mode 100644 index 00000000..5ea2ab89 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetPlayoutSettings.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Configuration; + +public record GetPlayoutSettings : IRequest; diff --git a/ErsatzTV.Application/Configuration/Queries/GetPlayoutSettingsHandler.cs b/ErsatzTV.Application/Configuration/Queries/GetPlayoutSettingsHandler.cs new file mode 100644 index 00000000..6807799a --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetPlayoutSettingsHandler.cs @@ -0,0 +1,26 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; + +namespace ErsatzTV.Application.Configuration; + +public class GetPlayoutSettingsHandler : IRequestHandler +{ + private readonly IConfigElementRepository _configElementRepository; + + public GetPlayoutSettingsHandler(IConfigElementRepository configElementRepository) => + _configElementRepository = configElementRepository; + + public async Task Handle(GetPlayoutSettings request, CancellationToken cancellationToken) + { + Option daysToBuild = await _configElementRepository.GetValue(ConfigElementKey.PlayoutDaysToBuild); + + Option skipMissingItems = + await _configElementRepository.GetValue(ConfigElementKey.PlayoutSkipMissingItems); + + return new PlayoutSettingsViewModel + { + DaysToBuild = await daysToBuild.IfNoneAsync(2), + SkipMissingItems = await skipMissingItems.IfNoneAsync(false) + }; + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs index 6a3ddd9f..8d89c985 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs @@ -70,6 +70,180 @@ public class PlayoutBuilderTests result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6)); } + [Test] + [Timeout(2000)] + public async Task OnlyFileNotFoundItem_Should_Abort() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(1), DateTime.Today) + }; + + mediaItems[0].State = MediaItemState.FileNotFound; + + var configRepo = new Mock(); + configRepo.Setup( + repo => repo.GetValue( + It.Is(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key))) + .ReturnsAsync(Some(true)); + + (PlayoutBuilder builder, Playout playout) = + TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo); + + Playout result = await builder.Build(playout, PlayoutBuildMode.Reset); + + configRepo.Verify(); + + result.Items.Should().BeEmpty(); + } + + [Test] + public async Task FileNotFoundItem_Should_BeSkipped() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(1), DateTime.Today), + TestMovie(2, TimeSpan.FromHours(6), DateTime.Today) + }; + + mediaItems[0].State = MediaItemState.FileNotFound; + + var configRepo = new Mock(); + configRepo.Setup( + repo => repo.GetValue( + It.Is(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key))) + .ReturnsAsync(Some(true)); + + (PlayoutBuilder builder, Playout playout) = + TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish); + + configRepo.Verify(); + + result.Items.Count.Should().Be(1); + result.Items.Head().MediaItemId.Should().Be(2); + result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero); + result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6)); + } + + [Test] + [Timeout(2000)] + public async Task OnlyUnavailableItem_Should_Abort() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(1), DateTime.Today) + }; + + mediaItems[0].State = MediaItemState.Unavailable; + + var configRepo = new Mock(); + configRepo.Setup( + repo => repo.GetValue( + It.Is(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key))) + .ReturnsAsync(Some(true)); + + (PlayoutBuilder builder, Playout playout) = + TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo); + + Playout result = await builder.Build(playout, PlayoutBuildMode.Reset); + + configRepo.Verify(); + + result.Items.Should().BeEmpty(); + } + + [Test] + public async Task UnavailableItem_Should_BeSkipped() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(1), DateTime.Today), + TestMovie(2, TimeSpan.FromHours(6), DateTime.Today) + }; + + mediaItems[0].State = MediaItemState.Unavailable; + + var configRepo = new Mock(); + configRepo.Setup( + repo => repo.GetValue( + It.Is(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key))) + .ReturnsAsync(Some(true)); + + (PlayoutBuilder builder, Playout playout) = + TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish); + + configRepo.Verify(); + + result.Items.Count.Should().Be(1); + result.Items.Head().MediaItemId.Should().Be(2); + result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero); + result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6)); + } + + [Test] + public async Task FileNotFound_Should_NotBeSkippedIfConfigured() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(6), DateTime.Today) + }; + + mediaItems[0].State = MediaItemState.FileNotFound; + + var configRepo = new Mock(); + configRepo.Setup( + repo => repo.GetValue( + It.Is(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key))) + .ReturnsAsync(Some(false)); + + (PlayoutBuilder builder, Playout playout) = + TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish); + + result.Items.Count.Should().Be(1); + result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero); + result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6)); + } + + [Test] + public async Task Unavailable_Should_NotBeSkippedIfConfigured() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(6), DateTime.Today) + }; + + mediaItems[0].State = MediaItemState.Unavailable; + + var configRepo = new Mock(); + configRepo.Setup( + repo => repo.GetValue( + It.Is(k => k.Key == ConfigElementKey.PlayoutSkipMissingItems.Key))) + .ReturnsAsync(Some(false)); + + (PlayoutBuilder builder, Playout playout) = + TestDataFloodForItems(mediaItems, PlaybackOrder.Random, configRepo); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish); + + result.Items.Count.Should().Be(1); + result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero); + result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6)); + } + [Test] public async Task InitialFlood_Should_StartAtMidnight() { @@ -2142,11 +2316,20 @@ public class PlayoutBuilderTests MovieMetadata = new List { new() { ReleaseDate = aired } }, MediaVersions = new List { - new() { Duration = duration } + new() + { + Duration = duration, MediaFiles = new List + { + new() { Path = $"/fake/path/{id}" } + } + } } }; - private TestData TestDataFloodForItems(List mediaItems, PlaybackOrder playbackOrder) + private TestData TestDataFloodForItems( + List mediaItems, + PlaybackOrder playbackOrder, + Mock configMock = null) { var mediaCollection = new Collection { @@ -2154,7 +2337,8 @@ public class PlayoutBuilderTests MediaItems = mediaItems }; - var configRepo = new Mock(); + Mock configRepo = configMock ?? new Mock(); + var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems))); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); diff --git a/ErsatzTV.Core/Domain/ConfigElementKey.cs b/ErsatzTV.Core/Domain/ConfigElementKey.cs index 3edc0ad0..6387bc02 100644 --- a/ErsatzTV.Core/Domain/ConfigElementKey.cs +++ b/ErsatzTV.Core/Domain/ConfigElementKey.cs @@ -33,4 +33,5 @@ public class ConfigElementKey public static ConfigElementKey FillerPresetsPageSize => new("pages.filler_presets.page_size"); public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval"); public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build"); + public static ConfigElementKey PlayoutSkipMissingItems => new("playout.skip_missing_items"); } diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index 27467c7e..8261f715 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -1,4 +1,5 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Scheduling; using LanguageExt.UnsafeValueAccess; @@ -234,7 +235,13 @@ public class PlayoutBuilder : IPlayoutBuilder return None; } - Option maybeEmptyCollection = await CheckForEmptyCollections(collectionMediaItems); + Option skipMissingItems = + await _configElementRepository.GetValue(ConfigElementKey.PlayoutSkipMissingItems); + + Option maybeEmptyCollection = await CheckForEmptyCollections( + collectionMediaItems, + await skipMissingItems.IfNoneAsync(false)); + foreach (CollectionKey emptyCollection in maybeEmptyCollection) { Option maybeName = await _mediaCollectionRepository.GetNameFromKey(emptyCollection); @@ -496,12 +503,13 @@ public class PlayoutBuilder : IPlayoutBuilder } private async Task> CheckForEmptyCollections( - Map> collectionMediaItems) + Map> collectionMediaItems, + bool skipMissingItems) { foreach ((CollectionKey _, List items) in collectionMediaItems) { var zeroItems = new List(); - // var missingItems = new List(); + var missingItems = new List(); foreach (MediaItem item in items) { @@ -520,18 +528,17 @@ public class PlayoutBuilder : IPlayoutBuilder _ => true }; - // if (item.State == MediaItemState.FileNotFound) - // { - // _logger.LogWarning( - // "Skipping media item that does not exist on disk {MediaItem} - {MediaItemTitle} - {Path}", - // item.Id, - // DisplayTitle(item), - // item.GetHeadVersion().MediaFiles.Head().Path); - // - // missingItems.Add(item); - // } - // else - if (isZero) + if (skipMissingItems && item.State is MediaItemState.FileNotFound or MediaItemState.Unavailable) + { + _logger.LogWarning( + "Skipping media item that does not exist on disk {MediaItem} - {MediaItemTitle} - {Path}", + item.Id, + DisplayTitle(item), + item.GetHeadVersion().MediaFiles.Head().Path); + + missingItems.Add(item); + } + else if (isZero) { _logger.LogWarning( "Skipping media item with zero duration {MediaItem} - {MediaItemTitle}", @@ -542,7 +549,7 @@ public class PlayoutBuilder : IPlayoutBuilder } } - // items.RemoveAll(missingItems.Contains); + items.RemoveAll(missingItems.Contains); items.RemoveAll(zeroItems.Contains); } diff --git a/ErsatzTV/Pages/Settings.razor b/ErsatzTV/Pages/Settings.razor index 0ec3548d..facb8f1d 100644 --- a/ErsatzTV/Pages/Settings.razor +++ b/ErsatzTV/Pages/Settings.razor @@ -5,8 +5,8 @@ @using ErsatzTV.Application.MediaItems @using ErsatzTV.Application.Watermarks @using System.Globalization -@using ErsatzTV.Core.Domain.Filler @using ErsatzTV.Application.Configuration +@using ErsatzTV.Core.Domain.Filler @implements IDisposable @inject IMediator _mediator @inject ISnackbar _snackbar @@ -147,12 +147,19 @@ + + + + + @@ -176,7 +183,7 @@ private List _fillerPresets; private int _tunerCount; private int _libraryRefreshInterval; - private int _playoutDaysToBuild; + private PlayoutSettingsViewModel _playoutSettings; public void Dispose() { @@ -198,8 +205,8 @@ _hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount)); _libraryRefreshInterval = await _mediator.Send(new GetLibraryRefreshInterval(), _cts.Token); _scannerSuccess = _libraryRefreshInterval > 0; - _playoutDaysToBuild = await _mediator.Send(new GetPlayoutDaysToBuild(), _cts.Token); - _playoutSuccess = _playoutDaysToBuild > 0; + _playoutSettings = await _mediator.Send(new GetPlayoutSettings(), _cts.Token); + _playoutSuccess = _playoutSettings.DaysToBuild > 0; } private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null; @@ -257,7 +264,7 @@ private async Task SavePlayoutSettings() { - Either result = await _mediator.Send(new UpdatePlayoutDaysToBuild(_playoutDaysToBuild), _cts.Token); + Either result = await _mediator.Send(new UpdatePlayoutSettings(_playoutSettings), _cts.Token); result.Match( Left: error => {