Browse Source

fix: classic playout playlist progress

pull/2966/head
Jason Dove 1 day ago
parent
commit
eaf253f104
No known key found for this signature in database
  1. 1
      CHANGELOG.md
  2. 14
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  3. 160
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlaylistAnchorRefreshTests.cs
  4. 16
      ErsatzTV.Core/Scheduling/CollectionKey.cs
  5. 70
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

1
CHANGELOG.md

@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed ### Fixed
- Fix regression from `v26.2.0` that caused channel logo watermarks to be ignored when the logo is a url - 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 - 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 ## [26.7.0] - 2026-07-27
### Added ### Added

14
ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs

@ -7,13 +7,20 @@ namespace ErsatzTV.Core.Tests.Fakes;
public class FakeMediaCollectionRepository : IMediaCollectionRepository public class FakeMediaCollectionRepository : IMediaCollectionRepository
{ {
private readonly Map<int, List<MediaItem>> _data; private readonly Map<int, List<MediaItem>> _data;
private readonly Map<int, Dictionary<PlaylistItem, List<MediaItem>>> _playlists;
public FakeMediaCollectionRepository(Map<int, List<MediaItem>> data) => _data = data; public FakeMediaCollectionRepository(
Map<int, List<MediaItem>> data,
Map<int, Dictionary<PlaylistItem, List<MediaItem>>> playlists = default)
{
_data = data;
_playlists = playlists;
}
public Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap( public Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(
int playlistId, int playlistId,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
throw new NotSupportedException(); _playlists[playlistId].AsTask();
public Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap( public Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(
string groupName, string groupName,
@ -44,7 +51,8 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
public Task<List<MediaItem>> GetRerunCollectionItems(int id, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task<List<MediaItem>> GetRerunCollectionItems(int id, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<List<MediaItem>> GetShowItemsByShowGuids(List<string> guids) => throw new NotSupportedException(); public Task<List<MediaItem>> GetShowItemsByShowGuids(List<string> guids) => throw new NotSupportedException();
public Task<List<MediaItem>> GetPlaylistItems(int id, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task<List<MediaItem>> GetPlaylistItems(int id, CancellationToken cancellationToken) =>
_playlists[id].Values.SelectMany(items => items).DistinctBy(mi => mi.Id).ToList().AsTask();
public Task<List<Movie>> GetMovie(int id) => throw new NotSupportedException(); public Task<List<Movie>> GetMovie(int id) => throw new NotSupportedException();
public Task<List<Episode>> GetEpisode(int id) => throw new NotSupportedException(); public Task<List<Episode>> GetEpisode(int id) => throw new NotSupportedException();
public Task<List<MusicVideo>> GetMusicVideo(int id) => throw new NotSupportedException(); public Task<List<MusicVideo>> GetMusicVideo(int id) => throw new NotSupportedException();

160
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<BaseError, PlayoutBuildResult> 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<MediaItem> mediaItems = Enumerable.Range(1, itemCount)
.Map(i => (MediaItem)TestMovie(i, itemDuration, new DateTime(2000 + i, 1, 1)))
.ToList();
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{
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<IConfigElementRepository>();
configRepo
.GetValue<int>(
Arg.Is<ConfigElementKey>(k => k.Key == ConfigElementKey.PlayoutDaysToBuild.Key),
Arg.Any<CancellationToken>())
.Returns(Some(DaysToBuild));
var builder = new PlayoutBuilder(
configRepo,
collectionRepo,
new FakeTelevisionRepository(),
Substitute.For<IArtistRepository>(),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
new MockFileSystem(),
Substitute.For<IRerunHelper>(),
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<Deco>.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);
}
}

16
ErsatzTV.Core/Scheduling/CollectionKey.cs

@ -16,6 +16,22 @@ public class CollectionKey : Record<CollectionKey>
public string SearchQuery { get; set; } public string SearchQuery { get; set; }
public string FakeCollectionKey { 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) => public static CollectionKey ForPlaylistItem(PlaylistItem item) =>
item.CollectionType switch item.CollectionType switch
{ {

70
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -185,67 +185,17 @@ public class PlayoutBuilder : IPlayoutBuilder
// _logger.LogDebug("Remaining anchors: {@Anchors}", playout.ProgramScheduleAnchors); // _logger.LogDebug("Remaining anchors: {@Anchors}", playout.ProgramScheduleAnchors);
var allAnchors = playout.ProgramScheduleAnchors.ToList(); // 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
var collectionIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.CollectionId)).Somes().ToHashSet(); // 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
var multiCollectionIds = List<PlayoutProgramScheduleAnchor> oldestAnchorPerKey = playout.ProgramScheduleAnchors
playout.ProgramScheduleAnchors.Map(a => Optional(a.MultiCollectionId)).Somes().ToHashSet(); .GroupBy(CollectionKey.ForAnchor)
.Map(g => g.MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks))
var smartCollectionIds = .ToList();
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();
playout.ProgramScheduleAnchors.Clear(); playout.ProgramScheduleAnchors.Clear();
playout.ProgramScheduleAnchors.AddRange(oldestAnchorPerKey);
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);
}
// _logger.LogDebug("Oldest anchors for each collection: {@Anchors}", playout.ProgramScheduleAnchors); // _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); // _logger.LogDebug("Final anchors: {@Anchors}", playout.ProgramScheduleAnchors);
// rewind to the start of today, so take the earliest rather than whichever comes first
Option<DateTime> maybeAnchorDate = playout.ProgramScheduleAnchors Option<DateTime> maybeAnchorDate = playout.ProgramScheduleAnchors
.Map(a => Optional(a.AnchorDate)) .Map(a => Optional(a.AnchorDate))
.Somes() .Somes()
.OrderBy(d => d)
.HeadOrNone(); .HeadOrNone();
foreach (DateTime anchorDate in maybeAnchorDate) foreach (DateTime anchorDate in maybeAnchorDate)

Loading…
Cancel
Save