From a076b3eb3061ef99405eab1907e0895d09e89099 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Fri, 10 Sep 2021 13:33:04 -0500 Subject: [PATCH] add shuffle-in-order support to all collections (#356) --- CHANGELOG.md | 4 + .../ProgramScheduleItemCommandBase.cs | 4 - .../Queries/GetProgramScheduleItemsHandler.cs | 1 + .../Fakes/FakeMediaCollectionRepository.cs | 3 + .../IMediaCollectionRepository.cs | 1 + ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 6 + .../Repositories/MediaCollectionRepository.cs | 163 ++++++++++++++++-- ErsatzTV/Pages/ScheduleItemsEditor.razor | 29 ++-- 8 files changed, 177 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dcb81ae..4de66ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add Smart Collections - Smart Collections use search queries and can be created from the search result page - Smart Collections are re-evaluated every time playouts are extended or rebuilt to automatically include newly-matching items +- Allow `Shuffle In Order` with Collections and Smart Collections + - Episodes will be grouped by show, and music videos will be grouped by artist + - All movies will be a single group (multi-collections are probably better if `Shuffle In Order` is desired for movies) + - All groups will be be ordered chronologically (custom ordering is only supported in multi-collections) ### Fixed - Generate XMLTV that validates successfully diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs index 0d96503c..6f7566d2 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs @@ -36,10 +36,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands break; } } - else if (item.PlaybackOrder == PlaybackOrder.ShuffleInOrder) - { - return BaseError.New("Invalid playback order: 'Shuffle In Order'"); - } switch (item.PlayoutMode) { diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs index f4fa3b3b..d9004230 100644 --- a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs @@ -28,6 +28,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries .Filter(psi => psi.ProgramScheduleId == request.Id) .Include(i => i.Collection) .Include(i => i.MultiCollection) + .Include(i => i.SmartCollection) .Include(i => i.MediaItem) .ThenInclude(i => (i as Movie).MovieMetadata) .ThenInclude(mm => mm.Artwork) diff --git a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs index 6b195d46..a5829fdb 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs @@ -25,6 +25,9 @@ namespace ErsatzTV.Core.Tests.Fakes public Task> GetMultiCollectionCollections(int id) => throw new NotSupportedException(); + public Task> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId) => + throw new NotSupportedException(); + public Task> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException(); public Task> PlayoutIdsUsingMultiCollection(int multiCollectionId) => diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs index 0828348e..cb82f022 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs @@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> GetMultiCollectionItems(int id); Task> GetSmartCollectionItems(int id); Task> GetMultiCollectionCollections(int id); + Task> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId); Task> PlayoutIdsUsingCollection(int collectionId); Task> PlayoutIdsUsingMultiCollection(int multiCollectionId); Task> PlayoutIdsUsingSmartCollection(int smartCollectionId); diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index 85b096e6..11b2226a 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -613,6 +613,12 @@ namespace ErsatzTV.Core.Scheduling result = await _mediaCollectionRepository.GetMultiCollectionCollections( collectionKey.MultiCollectionId.Value); } + else + { + result = await _mediaCollectionRepository.GetFakeMultiCollectionCollections( + collectionKey.CollectionId, + collectionKey.SmartCollectionId); + } return result; } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs index d9605350..e3772f98 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs @@ -103,18 +103,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .ToList(); result.AddRange(await GetMovieItems(dbContext, movieIds)); - var showIds = searchResults.Items - .Filter(i => i.Type == SearchIndex.ShowType) - .Map(i => i.Id) - .ToList(); - result.AddRange(await GetShowItems(dbContext, showIds)); + foreach (int showId in searchResults.Items.Filter(i => i.Type == SearchIndex.ShowType).Map(i => i.Id)) + { + result.AddRange(await GetShowItemsFromShowId(dbContext, showId)); + } + + foreach (int artistId in searchResults.Items.Filter(i => i.Type == SearchIndex.ArtistType) + .Map(i => i.Id)) + { + result.AddRange(await GetArtistItemsFromArtistId(dbContext, artistId)); + } - var artistIds = searchResults.Items - .Filter(i => i.Type == SearchIndex.ArtistType) - .Map(i => i.Id) - .ToList(); - result.AddRange(await GetArtistItems(dbContext, artistIds)); - var musicVideoIds = searchResults.Items .Filter(i => i.Type == SearchIndex.MusicVideoType) .Map(i => i.Id) @@ -196,6 +195,93 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return result; } + public async Task> GetFakeMultiCollectionCollections( + int? collectionId, + int? smartCollectionId) + { + var items = new List(); + + if (collectionId.HasValue) + { + items = await GetItems(collectionId.Value); + } + + if (smartCollectionId.HasValue) + { + items = await GetSmartCollectionItems(smartCollectionId.Value); + } + + return GroupIntoFakeCollections(items); + } + + private static List GroupIntoFakeCollections(List items) + { + int id = -1; + var result = new List(); + + var showCollections = new Dictionary>(); + foreach (Episode episode in items.OfType()) + { + List list = showCollections.ContainsKey(episode.Season.ShowId) + ? showCollections[episode.Season.ShowId] + : new List(); + + if (list.All(i => i.Id != episode.Id)) + { + list.Add(episode); + } + + showCollections[episode.Season.ShowId] = list; + } + + foreach ((int _, List list) in showCollections) + { + result.Add( + new CollectionWithItems( + id--, + list, + true, + PlaybackOrder.Chronological, + false)); + } + + var artistCollections = new Dictionary>(); + foreach (MusicVideo musicVideo in items.OfType()) + { + List list = artistCollections.ContainsKey(musicVideo.ArtistId) + ? artistCollections[musicVideo.ArtistId] + : new List(); + + if (list.All(i => i.Id != musicVideo.Id)) + { + list.Add(musicVideo); + } + + artistCollections[musicVideo.ArtistId] = list; + } + + foreach ((int _, List list) in artistCollections) + { + result.Add( + new CollectionWithItems( + id--, + list, + true, + PlaybackOrder.Chronological, + false)); + } + + result.Add( + new CollectionWithItems( + id, + items.OfType().Cast().ToList(), + true, + PlaybackOrder.Chronological, + false)); + + return result; + } + public Task> PlayoutIdsUsingCollection(int collectionId) => _dbConnection.QueryAsync( @"SELECT DISTINCT p.PlayoutId @@ -252,17 +338,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories WHERE ci.CollectionId = @CollectionId", new { CollectionId = collectionId }); - return await GetArtistItems(dbContext, ids); + return await GetArtistItemsFromMusicVideoIds(dbContext, ids); } - private static Task> GetArtistItems(TvContext dbContext, IEnumerable artistIds) => + private static Task> GetArtistItemsFromMusicVideoIds( + TvContext dbContext, + IEnumerable musicVideoIds) => dbContext.MusicVideos .Include(m => m.Artist) .ThenInclude(a => a.ArtistMetadata) .Include(m => m.MusicVideoMetadata) .Include(m => m.MediaVersions) - .Filter(m => artistIds.Contains(m.Id)) + .Filter(m => musicVideoIds.Contains(m.Id)) .ToListAsync(); + + private async Task> GetArtistItemsFromArtistId(TvContext dbContext, int artistId) + { + IEnumerable ids = await _dbConnection.QueryAsync( + @"SELECT MusicVideo.Id FROM Artist + INNER JOIN MusicVideo on Artist.Id = MusicVideo.ArtistId + WHERE Artist.Id = @ArtistId", + new { ArtistId = artistId }); + + return await GetArtistItemsFromMusicVideoIds(dbContext, ids); + } private async Task> GetMusicVideoItems(TvContext dbContext, int collectionId) { @@ -294,19 +393,31 @@ namespace ErsatzTV.Infrastructure.Data.Repositories WHERE ci.CollectionId = @CollectionId", new { CollectionId = collectionId }); - return await GetShowItems(dbContext, ids); + return await GetShowItemsFromEpisodeIds(dbContext, ids); } - private static Task> GetShowItems(TvContext dbContext, IEnumerable showIds) => + private static Task> GetShowItemsFromEpisodeIds(TvContext dbContext, IEnumerable episodeIds) => dbContext.Episodes .Include(e => e.EpisodeMetadata) .Include(e => e.MediaVersions) .Include(e => e.Season) .ThenInclude(s => s.Show) .ThenInclude(s => s.ShowMetadata) - .Filter(e => showIds.Contains(e.Id)) + .Filter(e => episodeIds.Contains(e.Id)) .ToListAsync(); + private async Task> GetShowItemsFromShowId(TvContext dbContext, int showId) + { + IEnumerable ids = await _dbConnection.QueryAsync( + @"SELECT Episode.Id FROM Show + INNER JOIN Season ON Season.ShowId = Show.Id + INNER JOIN Episode ON Episode.SeasonId = Season.Id + WHERE Show.Id = @ShowId", + new { ShowId = showId }); + + return await GetShowItemsFromEpisodeIds(dbContext, ids); + } + private async Task> GetSeasonItems(TvContext dbContext, int collectionId) { IEnumerable ids = await _dbConnection.QueryAsync( @@ -316,14 +427,28 @@ namespace ErsatzTV.Infrastructure.Data.Repositories WHERE ci.CollectionId = @CollectionId", new { CollectionId = collectionId }); - return await dbContext.Episodes + return await GetSeasonItemsFromEpisodeIds(dbContext, ids); + } + + private static Task> GetSeasonItemsFromEpisodeIds(TvContext dbContext, IEnumerable episodeIds) => + dbContext.Episodes .Include(e => e.EpisodeMetadata) .Include(e => e.MediaVersions) .Include(e => e.Season) .ThenInclude(s => s.Show) .ThenInclude(s => s.ShowMetadata) - .Filter(e => ids.Contains(e.Id)) + .Filter(e => episodeIds.Contains(e.Id)) .ToListAsync(); + + private async Task> GetSeasonItemsFromSeasonId(TvContext dbContext, int seasonId) + { + IEnumerable ids = await _dbConnection.QueryAsync( + @"SELECT Episode.Id FROM Season + INNER JOIN Episode ON Episode.SeasonId = Season.Id + WHERE Season.Id = @SeasonId", + new { SeasonId = seasonId }); + + return await GetSeasonItemsFromEpisodeIds(dbContext, ids); } private async Task> GetEpisodeItems(TvContext dbContext, int collectionId) diff --git a/ErsatzTV/Pages/ScheduleItemsEditor.razor b/ErsatzTV/Pages/ScheduleItemsEditor.razor index 456f412e..c3fa91ff 100644 --- a/ErsatzTV/Pages/ScheduleItemsEditor.razor +++ b/ErsatzTV/Pages/ScheduleItemsEditor.razor @@ -154,18 +154,25 @@ SearchFunc="@SearchArtists" ToStringFunc="@(s => s?.Name)"/> } - - @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.MultiCollection) + + @switch (_selectedItem.CollectionType) { - Shuffle - Shuffle In Order - } - else - { - Chronological - Random - Shuffle - @*Shuffle In Order*@ + case ProgramScheduleItemCollectionType.MultiCollection: + Shuffle + Shuffle In Order + break; + case ProgramScheduleItemCollectionType.Collection: + case ProgramScheduleItemCollectionType.SmartCollection: + Chronological + Random + Shuffle + Shuffle In Order + break; + default: + Chronological + Random + Shuffle + break; }