From ecf6c2c1ab2f915a85fcf0b1ffa6f903c07753a4 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:10:32 -0500 Subject: [PATCH] fix: classic playout playlist progress (#2966) --- CHANGELOG.md | 1 + .../Fakes/FakeMediaCollectionRepository.cs | 14 +- .../PlaylistAnchorRefreshTests.cs | 160 ++++++++++++++++++ ErsatzTV.Core/Scheduling/CollectionKey.cs | 16 ++ ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 70 ++------ 5 files changed, 199 insertions(+), 62 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlaylistAnchorRefreshTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e9ded1e..cba062908 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/). ### Fixed - Fix regression from `v26.2.0` that caused channel logo watermarks to be ignored when the logo is a url - This affected external logo urls and generated channel logos +- Maintain collection progress when refreshing a classic playout containing playlists ## [26.7.0] - 2026-07-27 ### Added diff --git a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs index db87f508c..ff3ffd1d6 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs @@ -7,13 +7,20 @@ namespace ErsatzTV.Core.Tests.Fakes; public class FakeMediaCollectionRepository : IMediaCollectionRepository { private readonly Map> _data; + private readonly Map>> _playlists; - public FakeMediaCollectionRepository(Map> data) => _data = data; + public FakeMediaCollectionRepository( + Map> data, + Map>> playlists = default) + { + _data = data; + _playlists = playlists; + } public Task>> GetPlaylistItemMap( int playlistId, CancellationToken cancellationToken) => - throw new NotSupportedException(); + _playlists[playlistId].AsTask(); public Task>> GetPlaylistItemMap( string groupName, @@ -44,7 +51,8 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository public Task> GetRerunCollectionItems(int id, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetShowItemsByShowGuids(List guids) => throw new NotSupportedException(); - public Task> GetPlaylistItems(int id, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetPlaylistItems(int id, CancellationToken cancellationToken) => + _playlists[id].Values.SelectMany(items => items).DistinctBy(mi => mi.Id).ToList().AsTask(); public Task> GetMovie(int id) => throw new NotSupportedException(); public Task> GetEpisode(int id) => throw new NotSupportedException(); public Task> GetMusicVideo(int id) => throw new NotSupportedException(); diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlaylistAnchorRefreshTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlaylistAnchorRefreshTests.cs new file mode 100644 index 000000000..77c08df96 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlaylistAnchorRefreshTests.cs @@ -0,0 +1,160 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Core.Tests.Fakes; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; + +namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; + +// refresh used to re-pick one anchor per collection with a bucket per key column, and there was no +// bucket for playlists, so every playlist anchor was dropped on every refresh +[TestFixture] +public class PlaylistAnchorRefreshTests : PlayoutBuilderTestBase +{ + private const int PlaylistId = 1; + private const int DaysToBuild = 2; + + [Test] + public async Task Refresh_Should_Not_Reset_Playlist_Progress() + { + // long enough that the playlist cycle can't complete, so the index measures progress directly + (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = + PlaylistTestData(TimeSpan.FromHours(6), itemCount: 300); + + DateTimeOffset start = LocalTime(20); + + for (var hour = 0; hour < 24 * 21; hour++) + { + await builder.Build( + start.AddHours(hour), + playout, + referenceData, + PlayoutBuildMode.Continue, + CancellationToken); + } + + int before = HeadIndex(playout); + before.ShouldBeGreaterThan(0, "the fast forward should have consumed some of the playlist"); + + Either result = await builder.Build( + start.AddDays(21), + playout, + referenceData, + PlayoutBuildMode.Refresh, + CancellationToken); + + result.IsRight.ShouldBeTrue(); + + // rewinding to the start of today legitimately moves the index back by up to a day; a refresh + // that dropped the anchor lands back at the beginning of the playlist + int after = HeadIndex(playout); + after.ShouldBeGreaterThan( + before / 2, + $"[tz {TimeZoneInfo.Local.Id}] refresh reset playlist progress (was {before}, now {after})"); + } + + private static int HeadIndex(Playout playout) => + playout.ProgramScheduleAnchors + .Filter(a => a.AnchorDate is null && a.PlaylistId == PlaylistId) + .Map(a => a.EnumeratorState.Index) + .HeadOrNone() + .IfNone(-1); + + private (PlayoutBuilder, Playout, PlayoutReferenceData) PlaylistTestData(TimeSpan itemDuration, int itemCount) + { + List mediaItems = Enumerable.Range(1, itemCount) + .Map(i => (MediaItem)TestMovie(i, itemDuration, new DateTime(2000 + i, 1, 1))) + .ToList(); + + var playlistItemMap = new Dictionary> + { + { + new PlaylistItem + { + Id = 1, + Index = 1, + PlaybackOrder = PlaybackOrder.Chronological, + PlayAll = true, + CollectionType = CollectionType.Collection, + CollectionId = 1 + }, + mediaItems + } + }; + + var collectionRepo = new FakeMediaCollectionRepository( + Map((1, mediaItems)), + Map((PlaylistId, playlistItemMap))); + + IConfigElementRepository configRepo = Substitute.For(); + configRepo + .GetValue( + Arg.Is(k => k.Key == ConfigElementKey.PlayoutDaysToBuild.Key), + Arg.Any()) + .Returns(Some(DaysToBuild)); + + var builder = new PlayoutBuilder( + configRepo, + collectionRepo, + new FakeTelevisionRepository(), + Substitute.For(), + Substitute.For(), + new MockFileSystem(), + Substitute.For(), + Logger); + + var schedule = new ProgramSchedule + { + Id = 1, + Items = + [ + new ProgramScheduleItemFlood + { + Id = 1, + Index = 1, + CollectionType = CollectionType.Playlist, + PlaylistId = PlaylistId, + StartTime = null, + PlaybackOrder = PlaybackOrder.Chronological + } + ] + }; + + var playout = new Playout + { + Id = 1, + ScheduleKind = PlayoutScheduleKind.Classic, + ProgramSchedule = schedule, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, + Items = [], + ProgramScheduleAnchors = [], + ProgramScheduleAlternates = [], + FillGroupIndices = [] + }; + + var referenceData = new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + schedule, + [], + [], + TimeSpan.Zero); + + return (builder, playout, referenceData); + } + + // anchor dates are compared in machine-local time, so the test clock has to be local too; mid-June + // to stay clear of DST transitions in every zone + private static DateTimeOffset LocalTime(int hour) + { + var date = new DateTime(2025, 6, 10, 0, 0, 0, DateTimeKind.Unspecified); + return new DateTimeOffset(date, TimeZoneInfo.Local.GetUtcOffset(date)) + TimeSpan.FromHours(hour); + } +} diff --git a/ErsatzTV.Core/Scheduling/CollectionKey.cs b/ErsatzTV.Core/Scheduling/CollectionKey.cs index 02483a057..3cc657ad1 100644 --- a/ErsatzTV.Core/Scheduling/CollectionKey.cs +++ b/ErsatzTV.Core/Scheduling/CollectionKey.cs @@ -16,6 +16,22 @@ public class CollectionKey : Record public string SearchQuery { get; set; } public string FakeCollectionKey { get; set; } + // the inverse of the other factories: every column that makes up an anchor's identity. + // keep in sync with PlayoutBuilder.AnchorMatchesCollectionKey + public static CollectionKey ForAnchor(PlayoutProgramScheduleAnchor anchor) => + new() + { + CollectionType = anchor.CollectionType, + CollectionId = anchor.CollectionId, + MultiCollectionId = anchor.MultiCollectionId, + SmartCollectionId = anchor.SmartCollectionId, + RerunCollectionId = anchor.RerunCollectionId, + MediaItemId = anchor.MediaItemId, + PlaylistId = anchor.PlaylistId, + SearchQuery = anchor.SearchQuery, + FakeCollectionKey = anchor.FakeCollectionKey + }; + public static CollectionKey ForPlaylistItem(PlaylistItem item) => item.CollectionType switch { diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index d95f60a09..fc0f4b5b6 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -185,67 +185,17 @@ public class PlayoutBuilder : IPlayoutBuilder // _logger.LogDebug("Remaining anchors: {@Anchors}", playout.ProgramScheduleAnchors); - var allAnchors = playout.ProgramScheduleAnchors.ToList(); - - var collectionIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.CollectionId)).Somes().ToHashSet(); - - var multiCollectionIds = - playout.ProgramScheduleAnchors.Map(a => Optional(a.MultiCollectionId)).Somes().ToHashSet(); - - var smartCollectionIds = - playout.ProgramScheduleAnchors.Map(a => Optional(a.SmartCollectionId)).Somes().ToHashSet(); - - var searchQueries = - playout.ProgramScheduleAnchors.Map(a => Optional(a.SearchQuery)).Somes().ToHashSet(); - - var rerunCollectionIds = - playout.ProgramScheduleAnchors.Map(a => Optional(a.RerunCollectionId)).Somes().ToHashSet(); - - var mediaItemIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.MediaItemId)).Somes().ToHashSet(); + // only today's checkpoints are left, and at most one per collection key per local date is + // ever written, so this should already be one anchor per key; keep the oldest in case an + // older database has more. group on the whole key, anything left out is silently dropped, + // which is how playlist and fake-collection anchors used to lose their progress + List oldestAnchorPerKey = playout.ProgramScheduleAnchors + .GroupBy(CollectionKey.ForAnchor) + .Map(g => g.MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks)) + .ToList(); playout.ProgramScheduleAnchors.Clear(); - - foreach (int collectionId in collectionIds) - { - PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.CollectionId == collectionId) - .MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); - playout.ProgramScheduleAnchors.Add(minAnchor); - } - - foreach (int multiCollectionId in multiCollectionIds) - { - PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.MultiCollectionId == multiCollectionId) - .MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); - playout.ProgramScheduleAnchors.Add(minAnchor); - } - - foreach (int smartCollectionId in smartCollectionIds) - { - PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.SmartCollectionId == smartCollectionId) - .MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); - playout.ProgramScheduleAnchors.Add(minAnchor); - } - - foreach (string searchQuery in searchQueries) - { - PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.SearchQuery == searchQuery) - .MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); - playout.ProgramScheduleAnchors.Add(minAnchor); - } - - foreach (int rerunCollectionId in rerunCollectionIds) - { - PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.RerunCollectionId == rerunCollectionId) - .MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); - playout.ProgramScheduleAnchors.Add(minAnchor); - } - - foreach (int mediaItemId in mediaItemIds) - { - PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.MediaItemId == mediaItemId) - .MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); - playout.ProgramScheduleAnchors.Add(minAnchor); - } + playout.ProgramScheduleAnchors.AddRange(oldestAnchorPerKey); // _logger.LogDebug("Oldest anchors for each collection: {@Anchors}", playout.ProgramScheduleAnchors); @@ -257,9 +207,11 @@ public class PlayoutBuilder : IPlayoutBuilder // _logger.LogDebug("Final anchors: {@Anchors}", playout.ProgramScheduleAnchors); + // rewind to the start of today, so take the earliest rather than whichever comes first Option maybeAnchorDate = playout.ProgramScheduleAnchors .Map(a => Optional(a.AnchorDate)) .Somes() + .OrderBy(d => d) .HeadOrNone(); foreach (DateTime anchorDate in maybeAnchorDate)