diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9818895..73d774b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix double scheduling; this could happen if the app was shutdown during a playout build - Fix updating Jellyfin and Emby TV seasons - Fix updating Jellyfin and Emby artwork +- Fix Plex, Jellyfin, Emby worker crash attempting to sync library that no longer exists +- Fix bug with `Duration` mode scheduling when media items are too long to fit in the requested duration +- Fix bug with `Duration` mode scheduling with `Filler` tail mode where other duration items in the schedule would be skipped ### Changed - Automatically find working Plex address on startup +- Change default log level from `Debug` to `Information` + - The `Debug` log level can be enabled in the `appsettings.json` file for non-docker installs + - The `Debug` log level can be enabled by setting the environment variable `Serilog:MinimumLevel=Debug` for docker installs ## [0.1.4-alpha] - 2021-10-14 ### Fixed diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs index a65bcc948..54f6ed945 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs @@ -1151,6 +1151,212 @@ namespace ErsatzTV.Core.Tests.Scheduling result.Anchor.NextScheduleItem.Should().Be(items[1]); result.Anchor.DurationFinish.Should().Be(HoursAfterMidnight(6).UtcDateTime); } + + [Test] + public async Task Alternating_Duration_With_Filler_Should_Alternate_Schedule_Items() + { + var collectionOne = new Collection + { + Id = 1, + Name = "Duration Items 1", + MediaItems = new List + { + TestMovie(1, TimeSpan.FromMinutes(55), new DateTime(2020, 1, 1)) + } + }; + + var collectionTwo = new Collection + { + Id = 2, + Name = "Duration Items 2", + MediaItems = new List + { + TestMovie(2, TimeSpan.FromMinutes(55), new DateTime(2020, 1, 1)) + } + }; + + var collectionThree = new Collection + { + Id = 3, + Name = "Filler Items", + MediaItems = new List + { + TestMovie(3, TimeSpan.FromMinutes(5), new DateTime(2020, 1, 1)) + } + }; + + var fakeRepository = new FakeMediaCollectionRepository( + Map( + (collectionOne.Id, collectionOne.MediaItems.ToList()), + (collectionTwo.Id, collectionTwo.MediaItems.ToList()), + (collectionThree.Id, collectionThree.MediaItems.ToList()))); + + var items = new List + { + new ProgramScheduleItemDuration + { + Id = 1, + Index = 1, + Collection = collectionOne, + CollectionId = collectionOne.Id, + StartTime = null, + PlayoutDuration = TimeSpan.FromHours(3), + PlaybackOrder = PlaybackOrder.Chronological, + TailMode = TailMode.Filler, + TailCollectionType = ProgramScheduleItemCollectionType.Collection, + TailCollection = collectionThree, + TailCollectionId = collectionThree.Id + }, + new ProgramScheduleItemDuration + { + Id = 2, + Index = 2, + Collection = collectionTwo, + CollectionId = collectionTwo.Id, + StartTime = null, + PlayoutDuration = TimeSpan.FromHours(3), + PlaybackOrder = PlaybackOrder.Chronological, + TailMode = TailMode.Filler, + TailCollectionType = ProgramScheduleItemCollectionType.Collection, + TailCollection = collectionThree, + TailCollectionId = collectionThree.Id + } + }; + + var playout = new Playout + { + ProgramSchedule = new ProgramSchedule + { + Items = items + }, + 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( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); + + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(12); + + result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromMinutes(0)); + result.Items[0].MediaItemId.Should().Be(1); + result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromMinutes(55)); + result.Items[1].MediaItemId.Should().Be(1); + result.Items[2].StartOffset.TimeOfDay.Should().Be(new TimeSpan(1, 50, 0)); + result.Items[2].MediaItemId.Should().Be(1); + + result.Items[3].StartOffset.TimeOfDay.Should().Be(new TimeSpan(2, 45, 0)); + result.Items[3].MediaItemId.Should().Be(3); + result.Items[4].StartOffset.TimeOfDay.Should().Be(new TimeSpan(2, 50, 0)); + result.Items[4].MediaItemId.Should().Be(3); + result.Items[5].StartOffset.TimeOfDay.Should().Be(new TimeSpan(2, 55, 0)); + result.Items[5].MediaItemId.Should().Be(3); + + result.Items[6].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3)); + result.Items[6].MediaItemId.Should().Be(2); + result.Items[7].StartOffset.TimeOfDay.Should().Be(new TimeSpan(3, 55, 0)); + result.Items[7].MediaItemId.Should().Be(2); + result.Items[8].StartOffset.TimeOfDay.Should().Be(new TimeSpan(4, 50, 0)); + result.Items[8].MediaItemId.Should().Be(2); + + result.Items[9].StartOffset.TimeOfDay.Should().Be(new TimeSpan(5, 45, 0)); + result.Items[9].MediaItemId.Should().Be(3); + result.Items[10].StartOffset.TimeOfDay.Should().Be(new TimeSpan(5, 50, 0)); + result.Items[10].MediaItemId.Should().Be(3); + result.Items[11].StartOffset.TimeOfDay.Should().Be(new TimeSpan(5, 55, 0)); + result.Items[11].MediaItemId.Should().Be(3); + + result.Anchor.NextScheduleItem.Should().Be(items[0]); + result.Anchor.DurationFinish.Should().BeNull(); + } + + [Test] + public async Task Duration_Should_Skip_Items_That_Are_Too_Long() + { + var collectionOne = new Collection + { + Id = 1, + Name = "Duration Items 1", + MediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)), + TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), + TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)), + TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)) + } + }; + + var fakeRepository = + new FakeMediaCollectionRepository(Map((collectionOne.Id, collectionOne.MediaItems.ToList()))); + + var items = new List + { + new ProgramScheduleItemDuration + { + Id = 1, + Index = 1, + Collection = collectionOne, + CollectionId = collectionOne.Id, + StartTime = null, + PlayoutDuration = TimeSpan.FromHours(1), + PlaybackOrder = PlaybackOrder.Chronological, + TailMode = TailMode.None, + } + }; + + var playout = new Playout + { + ProgramSchedule = new ProgramSchedule + { + Items = items + }, + 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( + configRepo.Object, + fakeRepository, + televisionRepo, + artistRepo.Object, + _logger); + + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.BuildPlayoutItems(playout, start, finish); + + result.Items.Count.Should().Be(6); + + result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(0)); + result.Items[0].MediaItemId.Should().Be(2); + result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1)); + result.Items[1].MediaItemId.Should().Be(4); + result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2)); + result.Items[2].MediaItemId.Should().Be(2); + result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3)); + result.Items[3].MediaItemId.Should().Be(4); + result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4)); + result.Items[4].MediaItemId.Should().Be(2); + result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(5)); + result.Items[5].MediaItemId.Should().Be(4); + + result.Anchor.NextScheduleItem.Should().Be(items[0]); + result.Anchor.DurationFinish.Should().BeNull(); + } private static DateTimeOffset HoursAfterMidnight(int hours) { diff --git a/ErsatzTV.Core/Domain/PlayoutItem.cs b/ErsatzTV.Core/Domain/PlayoutItem.cs index 803925888..da68a63ef 100644 --- a/ErsatzTV.Core/Domain/PlayoutItem.cs +++ b/ErsatzTV.Core/Domain/PlayoutItem.cs @@ -1,7 +1,9 @@ using System; +using System.Diagnostics; namespace ErsatzTV.Core.Domain { + [DebuggerDisplay("{MediaItemId} - {Start} - {Finish}")] public class PlayoutItem { public int Id { get; set; } diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index aba27de81..8aa6bdd55 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -302,9 +302,20 @@ namespace ErsatzTV.Core.Scheduling playoutItem.CustomTitle = scheduleItem.CustomTitle; } - currentTime = itemStartTime + version.Duration; enumerator.MoveNext(); + + if (scheduleItem is ProgramScheduleItemDuration d && + version.Duration > d.PlayoutDuration) + { + _logger.LogWarning( + "Skipping playout item {Title} with duration {Duration} that is longer than schedule item duration {PlayoutDuration}", + DisplayTitle(mediaItem), + version.Duration, + d.PlayoutDuration); + return; + } + currentTime = itemStartTime + version.Duration; playout.Items.Add(playoutItem); switch (scheduleItem) @@ -442,6 +453,10 @@ namespace ErsatzTV.Core.Scheduling durationFinish, collectionEnumerators)) { + // if we're starting filler, we don't actually need to move + // to the next schedule item yet + index--; + inDurationFiller = true; durationFinish.Do( f => playoutItem.GuideFinish = f.UtcDateTime); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index 3d3226557..0218dbab4 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -81,7 +81,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public async Task> GetPlexByLibraryId(int plexLibraryId) { - int? id = await _dbConnection.QuerySingleAsync( + int? id = await _dbConnection.QuerySingleOrDefaultAsync( @"SELECT L.MediaSourceId FROM Library L INNER JOIN PlexLibrary PL on L.Id = PL.Id WHERE L.Id = @PlexLibraryId", @@ -562,7 +562,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public async Task> GetJellyfinByLibraryId(int jellyfinLibraryId) { - int? id = await _dbConnection.QuerySingleAsync( + int? id = await _dbConnection.QuerySingleOrDefaultAsync( @"SELECT L.MediaSourceId FROM Library L INNER JOIN JellyfinLibrary PL on L.Id = PL.Id WHERE L.Id = @JellyfinLibraryId", @@ -745,7 +745,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public async Task> GetEmbyByLibraryId(int embyLibraryId) { - int? id = await _dbConnection.QuerySingleAsync( + int? id = await _dbConnection.QuerySingleOrDefaultAsync( @"SELECT L.MediaSourceId FROM Library L INNER JOIN EmbyLibrary PL on L.Id = PL.Id WHERE L.Id = @EmbyLibraryId", diff --git a/ErsatzTV/appsettings.json b/ErsatzTV/appsettings.json index 2921d7a8d..95544739b 100644 --- a/ErsatzTV/appsettings.json +++ b/ErsatzTV/appsettings.json @@ -4,7 +4,7 @@ "Serilog.Sinks.Console" ], "MinimumLevel": { - "Default": "Debug", + "Default": "Information", "Override": { "Microsoft": "Warning", "System.Net.Http.HttpClient": "Warning"