Browse Source

custom collection playback order (#64)

* add custom index to collection items

* add custom collection order to ui

* cleanup
pull/65/head v0.0.16-prealpha
Jason Dove 4 years ago committed by GitHub
parent
commit
4953617f79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs
  2. 13
      ErsatzTV.Application/MediaCards/Mapper.cs
  3. 2
      ErsatzTV.Application/MediaCards/MediaCardViewModel.cs
  4. 2
      ErsatzTV.Application/MediaCards/MovieCardViewModel.cs
  5. 1
      ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs
  6. 1
      ErsatzTV.Application/MediaCards/TelevisionSeasonCardViewModel.cs
  7. 1
      ErsatzTV.Application/MediaCards/TelevisionShowCardViewModel.cs
  8. 6
      ErsatzTV.Application/MediaCollections/Commands/UpdateCollection.cs
  9. 13
      ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionCustomOrder.cs
  10. 66
      ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionCustomOrderHandler.cs
  11. 27
      ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionHandler.cs
  12. 1
      ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs
  13. 7
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  14. 100
      ErsatzTV.Core.Tests/Scheduling/CustomOrderContentTests.cs
  15. 1
      ErsatzTV.Core/Domain/Collection/Collection.cs
  16. 1
      ErsatzTV.Core/Domain/Collection/CollectionItem.cs
  17. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs
  18. 38
      ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
  19. 60
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  20. 11
      ErsatzTV.Infrastructure/Data/Configurations/Collection/CollectionItemConfiguration.cs
  21. 14
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  22. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  23. 1626
      ErsatzTV.Infrastructure/Migrations/20210312113202_Add_CollectionItem_CustomIndex.Designer.cs
  24. 19
      ErsatzTV.Infrastructure/Migrations/20210312113202_Add_CollectionItem_CustomIndex.cs
  25. 1629
      ErsatzTV.Infrastructure/Migrations/20210312183644_Add_Collection_UseCustomPlaybackOrder.Designer.cs
  26. 20
      ErsatzTV.Infrastructure/Migrations/20210312183644_Add_Collection_UseCustomPlaybackOrder.cs
  27. 6
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  28. 35
      ErsatzTV/Controllers/SortController.cs
  29. 49
      ErsatzTV/Pages/CollectionItems.razor
  30. 26
      ErsatzTV/Pages/_Host.cshtml
  31. 2
      ErsatzTV/Shared/MediaCard.razor

5
ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs

@ -7,5 +7,8 @@ namespace ErsatzTV.Application.MediaCards @@ -7,5 +7,8 @@ namespace ErsatzTV.Application.MediaCards
List<MovieCardViewModel> MovieCards,
List<TelevisionShowCardViewModel> ShowCards,
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards);
List<TelevisionEpisodeCardViewModel> EpisodeCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}
}

13
ErsatzTV.Application/MediaCards/Mapper.cs

@ -52,11 +52,20 @@ namespace ErsatzTV.Application.MediaCards @@ -52,11 +52,20 @@ namespace ErsatzTV.Application.MediaCards
ProjectToViewModel(Collection collection) =>
new(
collection.Name,
collection.MediaItems.OfType<Movie>().Map(m => ProjectToViewModel(m.MovieMetadata.Head())).ToList(),
collection.MediaItems.OfType<Movie>().Map(
m => ProjectToViewModel(m.MovieMetadata.Head()) with
{
CustomIndex = GetCustomIndex(collection, m.Id)
}).ToList(),
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList(),
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
.ToList());
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
Optional(collection.CollectionItems.Find(ci => ci.MediaItemId == mediaItemId))
.Map(ci => ci.CustomIndex ?? 0)
.IfNone(0);
internal static SearchCardResultsViewModel ProjectToSearchResults(List<MediaItem> items) =>
new(

2
ErsatzTV.Application/MediaCards/MediaCardViewModel.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
namespace ErsatzTV.Application.MediaCards
{
public record MediaCardViewModel(string Title, string Subtitle, string SortTitle, string Poster);
public record MediaCardViewModel(int MediaItemId, string Title, string Subtitle, string SortTitle, string Poster);
}

2
ErsatzTV.Application/MediaCards/MovieCardViewModel.cs

@ -2,10 +2,12 @@ @@ -2,10 +2,12 @@
{
public record MovieCardViewModel
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster)
{
public int CustomIndex { get; set; }
}
}

1
ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs

@ -13,6 +13,7 @@ namespace ErsatzTV.Application.MediaCards @@ -13,6 +13,7 @@ namespace ErsatzTV.Application.MediaCards
string Title,
string Plot,
string Poster) : MediaCardViewModel(
EpisodeId,
Title,
$"Episode {Episode}",
$"Episode {Episode}",

1
ErsatzTV.Application/MediaCards/TelevisionSeasonCardViewModel.cs

@ -10,6 +10,7 @@ @@ -10,6 +10,7 @@
string SortTitle,
string Poster,
string Placeholder) : MediaCardViewModel(
TelevisionSeasonId,
Title,
Subtitle,
SortTitle,

1
ErsatzTV.Application/MediaCards/TelevisionShowCardViewModel.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
{
public record TelevisionShowCardViewModel
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,

6
ErsatzTV.Application/MediaCollections/Commands/UpdateCollection.cs

@ -1,8 +1,12 @@ @@ -1,8 +1,12 @@
using ErsatzTV.Core;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateCollection
(int CollectionId, string Name) : MediatR.IRequest<Either<BaseError, Unit>>;
(int CollectionId, string Name) : MediatR.IRequest<Either<BaseError, Unit>>
{
public Option<bool> UseCustomPlaybackOrder { get; set; } = None;
}
}

13
ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionCustomOrder.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateCollectionCustomOrder
(
int CollectionId,
List<MediaItemCustomOrder> MediaItemCustomOrders) : MediatR.IRequest<Either<BaseError, Unit>>;
public record MediaItemCustomOrder(int MediaItemId, int CustomIndex);
}

66
ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionCustomOrderHandler.cs

@ -0,0 +1,66 @@ @@ -0,0 +1,66 @@
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
UpdateCollectionCustomOrderHandler : MediatR.IRequestHandler<UpdateCollectionCustomOrder,
Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateCollectionCustomOrderHandler(
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
UpdateCollectionCustomOrder request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(c => ApplyUpdateRequest(c, request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollectionCustomOrder request)
{
foreach (MediaItemCustomOrder updateItem in request.MediaItemCustomOrders)
{
Option<CollectionItem> maybeCollectionItem =
c.CollectionItems.FirstOrDefault(ci => ci.MediaItemId == updateItem.MediaItemId);
maybeCollectionItem.IfSome(ci => ci.CustomIndex = updateItem.CustomIndex);
}
if (await _mediaCollectionRepository.Update(c))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private Task<Validation<BaseError, Collection>> Validate(UpdateCollectionCustomOrder request) =>
CollectionMustExist(request);
private Task<Validation<BaseError, Collection>> CollectionMustExist(
UpdateCollectionCustomOrder request) =>
_mediaCollectionRepository.Get(request.CollectionId)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
}
}

27
ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionHandler.cs

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@ -9,10 +11,16 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -9,10 +11,16 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
public class UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
public UpdateCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
UpdateCollection request,
@ -21,10 +29,21 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -21,10 +29,21 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(c => ApplyUpdateRequest(c, request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection update)
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection request)
{
c.Name = update.Name;
await _mediaCollectionRepository.Update(c);
c.Name = request.Name;
request.UseCustomPlaybackOrder.IfSome(
useCustomPlaybackOrder => c.UseCustomPlaybackOrder = useCustomPlaybackOrder);
if (await _mediaCollectionRepository.Update(c) && request.UseCustomPlaybackOrder.IsSome)
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}

1
ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
namespace ErsatzTV.Application.MediaCollections
{
public record MediaCollectionViewModel(int Id, string Name) : MediaCardViewModel(
Id,
Name,
string.Empty,
Name,

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

@ -14,17 +14,22 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -14,17 +14,22 @@ namespace ErsatzTV.Core.Tests.Fakes
private readonly Map<int, List<MediaItem>> _data;
public FakeMediaCollectionRepository(Map<int, List<MediaItem>> data) => _data = data;
public Task<Collection> Add(Collection collection) => throw new NotSupportedException();
public Task<bool> AddMediaItem(int collectionId, int mediaItemId) => throw new NotSupportedException();
public Task<bool> AddMediaItems(int collectionId, List<int> mediaItemIds) => throw new NotSupportedException();
public Task<Option<Collection>> Get(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithItems(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithItemsUntracked(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id) =>
throw new NotSupportedException();
public Task<List<Collection>> GetAll() => throw new NotSupportedException();
public Task<Option<List<MediaItem>>> GetItems(int id) => Some(_data[id].ToList()).AsTask();
Task<bool> IMediaCollectionRepository.Update(Collection collection) => throw new NotSupportedException();
public Task Delete(int collectionId) => throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
}
}

100
ErsatzTV.Core.Tests/Scheduling/CustomOrderContentTests.cs

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using FluentAssertions;
using NUnit.Framework;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Tests.Scheduling
{
public class CustomOrderContentTests
{
[Test]
public void MediaItems_Should_Sort_By_CustomOrder()
{
Collection collection = CreateCollection(10);
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState();
var customOrderContent = new CustomOrderCollectionEnumerator(collection, contents, state);
for (var i = 10; i >= 1; i--)
{
customOrderContent.Current.IsSome.Should().BeTrue();
customOrderContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i);
customOrderContent.MoveNext();
}
}
[Test]
public void State_Index_Should_Increment()
{
Collection collection = CreateCollection(10);
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState();
var customOrderContent = new CustomOrderCollectionEnumerator(collection, contents, state);
for (var i = 0; i < 10; i++)
{
customOrderContent.State.Index.Should().Be(i % 10);
customOrderContent.MoveNext();
}
}
[Test]
public void State_Should_Impact_Iterator_Start()
{
Collection collection = CreateCollection(10);
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 5 };
var customOrderContent = new CustomOrderCollectionEnumerator(collection, contents, state);
for (var i = 5; i >= 1; i--)
{
customOrderContent.Current.IsSome.Should().BeTrue();
customOrderContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i);
customOrderContent.State.Index.Should().Be(5 - i + 5); // 5 through 10
customOrderContent.MoveNext();
}
}
private static Collection CreateCollection(int episodeCount)
{
var collection = new Collection { CollectionItems = new List<CollectionItem>() };
for (var i = 1; i <= episodeCount; i++)
{
collection.CollectionItems.Add(
new CollectionItem
{
MediaItemId = i,
// reverse order
CustomIndex = episodeCount - i
});
}
return collection;
}
private static List<MediaItem> Episodes(int count) =>
Range(1, count).Map(
i => (MediaItem) new Episode
{
Id = i,
EpisodeMetadata = new List<EpisodeMetadata>
{
new()
{
ReleaseDate = new DateTime(2020, 1, i)
}
}
})
.Reverse()
.ToList();
}
}

1
ErsatzTV.Core/Domain/Collection/Collection.cs

@ -6,6 +6,7 @@ namespace ErsatzTV.Core.Domain @@ -6,6 +6,7 @@ namespace ErsatzTV.Core.Domain
{
public int Id { get; set; }
public string Name { get; set; }
public bool UseCustomPlaybackOrder { get; set; }
public List<MediaItem> MediaItems { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
}

1
ErsatzTV.Core/Domain/Collection/CollectionItem.cs

@ -6,5 +6,6 @@ @@ -6,5 +6,6 @@
public Collection Collection { get; set; }
public int MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public int? CustomIndex { get; set; }
}
}

2
ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs

@ -13,10 +13,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -13,10 +13,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<Collection>> Get(int id);
Task<Option<Collection>> GetCollectionWithItems(int id);
Task<Option<Collection>> GetCollectionWithItemsUntracked(int id);
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id);
Task<List<Collection>> GetAll();
Task<Option<List<MediaItem>>> GetItems(int id);
Task<bool> Update(Collection collection);
Task Delete(int collectionId);
Task<List<int>> PlayoutIdsUsingCollection(int collectionId);
Task<bool> IsCustomPlaybackOrder(int collectionId);
}
}

38
ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Scheduling
{
public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
{
private readonly IList<MediaItem> _sortedMediaItems;
public CustomOrderCollectionEnumerator(
Collection collection,
List<MediaItem> mediaItems,
CollectionEnumeratorState state)
{
// TODO: this will break if we allow shows and seasons
_sortedMediaItems = collection.CollectionItems
.OrderBy(ci => ci.CustomIndex)
.Map(ci => mediaItems.First(mi => mi.Id == ci.MediaItemId))
.ToList();
State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)
{
MoveNext();
}
}
public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
}
}

60
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -78,7 +78,8 @@ namespace ErsatzTV.Core.Scheduling @@ -78,7 +78,8 @@ namespace ErsatzTV.Core.Scheduling
playout.Channel.Number,
playout.Channel.Name);
Option<CollectionKey> emptyCollection = collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
Option<CollectionKey> emptyCollection =
collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
if (emptyCollection.IsSome)
{
_logger.LogError(
@ -117,9 +118,15 @@ namespace ErsatzTV.Core.Scheduling @@ -117,9 +118,15 @@ namespace ErsatzTV.Core.Scheduling
playout.ProgramScheduleAnchors.Clear();
}
var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
Map<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators =
MapExtensions.Map(collectionMediaItems, (c, i) => GetMediaCollectionEnumerator(playout, c, i));
var sortedScheduleItems =
playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems)
{
IMediaCollectionEnumerator enumerator =
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems);
collectionEnumerators.Add(collectionKey, enumerator);
}
// find start anchor
PlayoutAnchor startAnchor = FindStartAnchor(playout, playoutStart, sortedScheduleItems);
@ -357,20 +364,20 @@ namespace ErsatzTV.Core.Scheduling @@ -357,20 +364,20 @@ namespace ErsatzTV.Core.Scheduling
private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors(
Playout playout,
Map<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators)
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators)
{
var result = new List<PlayoutProgramScheduleAnchor>();
foreach (CollectionKey collectionKey in collectionEnumerators.Keys)
{
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors
.FirstOrDefault(
a => a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault(
a => a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State)
.ToDictionary(mcs => mcs.Key, mcs => mcs.Head());
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary(
mcs => mcs.Key,
mcs => mcs.Head());
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match(
existing =>
@ -396,22 +403,37 @@ namespace ErsatzTV.Core.Scheduling @@ -396,22 +403,37 @@ namespace ErsatzTV.Core.Scheduling
return result;
}
private static IMediaCollectionEnumerator GetMediaCollectionEnumerator(
private async Task<IMediaCollectionEnumerator> GetMediaCollectionEnumerator(
Playout playout,
CollectionKey collectionKey,
List<MediaItem> mediaItems)
{
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
.FirstOrDefault(
a => a.ProgramScheduleId == playout.ProgramScheduleId
&& a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors.FirstOrDefault(
a => a.ProgramScheduleId == playout.ProgramScheduleId
&& a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
CollectionEnumeratorState state = maybeAnchor.Match(
anchor => anchor.EnumeratorState,
() => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0))
{
Option<Collection> collectionWithItems =
await _mediaCollectionRepository.GetCollectionWithCollectionItemsUntracked(
collectionKey.CollectionId ?? 0);
if (collectionKey.CollectionType == ProgramScheduleItemCollectionType.Collection &&
collectionWithItems.IsSome)
{
return new CustomOrderCollectionEnumerator(
collectionWithItems.ValueUnsafe(),
mediaItems,
state);
}
}
switch (playout.ProgramSchedule.MediaCollectionPlaybackOrder)
{
case PlaybackOrder.Chronological:

11
ErsatzTV.Infrastructure/Data/Configurations/Collection/CollectionItemConfiguration.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class CollectionItemConfiguration : IEntityTypeConfiguration<CollectionItem>
{
public void Configure(EntityTypeBuilder<CollectionItem> builder) => builder.ToTable("CollectionItem");
}
}

14
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -92,6 +92,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -92,6 +92,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<Option<Collection>> Get(int id) =>
_dbContext.Collections
.Include(c => c.CollectionItems)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
@ -120,6 +121,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -120,6 +121,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<Option<Collection>> GetCollectionWithItemsUntracked(int id) =>
_dbContext.Collections
.AsNoTracking()
.Include(c => c.CollectionItems)
.Include(c => c.MediaItems)
.ThenInclude(i => i.LibraryPath)
.Include(c => c.MediaItems)
@ -145,6 +147,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -145,6 +147,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
public Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id) =>
_dbContext.Collections
.Include(c => c.CollectionItems)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
public Task<List<Collection>> GetAll() =>
_dbContext.Collections.ToListAsync();
@ -174,6 +183,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -174,6 +183,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { CollectionId = collectionId })
.Map(result => result.ToList());
public Task<bool> IsCustomPlaybackOrder(int collectionId) =>
_dbConnection.QuerySingleAsync<bool>(
@"SELECT UseCustomPlaybackOrder FROM Collection WHERE Id = @CollectionId",
new { CollectionId = collectionId });
private async Task<List<MediaItem>> GetItemsForCollection(Collection collection)
{
var result = new List<MediaItem>();

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -34,6 +34,7 @@ namespace ErsatzTV.Infrastructure.Data @@ -34,6 +34,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
public DbSet<PlexMovie> PlexMovies { get; set; }
public DbSet<Collection> Collections { get; set; }
public DbSet<CollectionItem> CollectionItems { get; set; }
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
public DbSet<Playout> Playouts { get; set; }
public DbSet<PlayoutItem> PlayoutItems { get; set; }

1626
ErsatzTV.Infrastructure/Migrations/20210312113202_Add_CollectionItem_CustomIndex.Designer.cs generated

File diff suppressed because it is too large Load Diff

19
ErsatzTV.Infrastructure/Migrations/20210312113202_Add_CollectionItem_CustomIndex.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_CollectionItem_CustomIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.AddColumn<int>(
"CustomIndex",
"CollectionItem",
"INTEGER",
nullable: true);
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"CustomIndex",
"CollectionItem");
}
}

1629
ErsatzTV.Infrastructure/Migrations/20210312183644_Add_Collection_UseCustomPlaybackOrder.Designer.cs generated

File diff suppressed because it is too large Load Diff

20
ErsatzTV.Infrastructure/Migrations/20210312183644_Add_Collection_UseCustomPlaybackOrder.cs

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_Collection_UseCustomPlaybackOrder : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.AddColumn<bool>(
"UseCustomPlaybackOrder",
"Collection",
"INTEGER",
nullable: false,
defaultValue: false);
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"UseCustomPlaybackOrder",
"Collection");
}
}

6
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -110,6 +110,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -110,6 +110,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("UseCustomPlaybackOrder")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Collection");
@ -125,6 +128,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -125,6 +128,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b.Property<int?>("CustomIndex")
.HasColumnType("INTEGER");
b.HasKey("CollectionId", "MediaItemId");
b.HasIndex("MediaItemId");

35
ErsatzTV/Controllers/SortController.cs

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCollections.Commands;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers
{
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public class SortController : ControllerBase
{
private readonly IMediator _mediator;
public SortController(IMediator mediator) => _mediator = mediator;
[HttpPost("media/collections/{collectionId}/items")]
public Task SortCollectionItems(
int collectionId,
[FromForm]
SortedMediaItemIds sortedMediaItemIds)
{
var ids = sortedMediaItemIds.Item.Map(int.Parse).ToList();
var request = new UpdateCollectionCustomOrder(
collectionId,
ids.Map(i => new MediaItemCustomOrder(i, ids.IndexOf(i))).ToList());
return _mediator.Send(request);
}
}
public record SortedMediaItemIds(List<string> Item);
}

49
ErsatzTV/Pages/CollectionItems.razor

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
@inherits MultiSelectBase<CollectionItems>
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
@inject IJSRuntime JsRuntime
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="align-items: center; display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%;" class="ml-6 mr-6">
@ -51,6 +52,16 @@ @@ -51,6 +52,16 @@
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
}
@if (SupportsCustomOrdering())
{
<div style="margin-left: auto">
<MudSwitch T="bool"
Checked="@_data.UseCustomPlaybackOrder"
Color="Color.Primary"
CheckedChanged="@OnUseCustomOrderChanged"
Label="Use Custom Playback Order"/>
</div>
}
}
</div>
</MudPaper>
@ -65,8 +76,8 @@ @@ -65,8 +76,8 @@
Movies
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle))
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid" UserAttributes="@(new Dictionary<string, object> { { "id", "sortable-collection" } })">
@foreach (MovieCardViewModel card in OrderMovies(_data.MovieCards))
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
@ -163,6 +174,9 @@ @@ -163,6 +174,9 @@
private CollectionCardResultsViewModel _data;
private bool SupportsCustomOrdering() =>
_data.MovieCards.Any() && !_data.ShowCards.Any() && !_data.SeasonCards.Any() && !_data.EpisodeCards.Any();
protected override async Task OnParametersSetAsync() => await RefreshData();
protected override async Task RefreshData()
@ -175,6 +189,30 @@ @@ -175,6 +189,30 @@
error => NavigationManager.NavigateTo("404"));
}
private IOrderedEnumerable<MovieCardViewModel> OrderMovies(List<MovieCardViewModel> movies)
{
if (_data.UseCustomPlaybackOrder)
{
return movies.OrderBy(m => m.CustomIndex);
}
return movies.OrderBy(m => m.SortTitle);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JsRuntime.InvokeVoidAsync("sortableCollection", Id);
if (_data.UseCustomPlaybackOrder)
{
await JsRuntime.InvokeVoidAsync("enableSorting");
}
else
{
await JsRuntime.InvokeVoidAsync("disableSorting");
}
await base.OnAfterRenderAsync(firstRender);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
@ -255,4 +293,11 @@ @@ -255,4 +293,11 @@
}
}
private async Task OnUseCustomOrderChanged()
{
_data.UseCustomPlaybackOrder = !_data.UseCustomPlaybackOrder;
var request = new UpdateCollection(Id, _data.Name) { UseCustomPlaybackOrder = _data.UseCustomPlaybackOrder };
await Mediator.Send(request);
}
}

26
ErsatzTV/Pages/_Host.cshtml

@ -17,7 +17,33 @@ @@ -17,7 +17,33 @@
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet"/>
<link href="css/site.css" rel="stylesheet"/>
<link href="ErsatzTV.styles.css" rel="stylesheet"/>
<link href="//code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css" rel="stylesheet">
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
@await Html.PartialAsync("../Shared/_Favicons")
<script>
function sortableCollection(collectionId) {
$("#sortable-collection").sortable({
update: function(event, ui) {
const data = $(this).sortable('serialize');
$.ajax({
data: data,
type: 'POST',
url: `/media/collections/${collectionId}/items`
});
}
});
$("#sortable-collection").disableSelection();
}
function disableSorting() {
$("#sortable-collection").sortable("option", "disabled", true);
}
function enableSorting() {
$("#sortable-collection").sortable("option", "disabled", false);
}
</script>
</head>
<body>
<component type="typeof(App)" render-mode="ServerPrerendered"/>

2
ErsatzTV/Shared/MediaCard.razor

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
@using Unit = LanguageExt.Unit
@inject IMediator Mediator
<div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")">
<div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")" id="@($"item_{Data.MediaItemId}")">
@if (!string.IsNullOrWhiteSpace(Link))
{
<div class="@(IsSelected ? DeleteClicked.HasDelegate ? "media-card-selected-delete" : "media-card-selected" : "")"

Loading…
Cancel
Save