custom collection playback order (#64)
* add custom index to collection items * add custom collection order to ui * cleanup
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
string Title,
|
||||
string Plot,
|
||||
string Poster) : MediaCardViewModel(
|
||||
EpisodeId,
|
||||
Title,
|
||||
$"Episode {Episode}",
|
||||
$"Episode {Episode}",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
string SortTitle,
|
||||
string Poster,
|
||||
string Placeholder) : MediaCardViewModel(
|
||||
TelevisionSeasonId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
public record TelevisionShowCardViewModel
|
||||
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
|
||||
TelevisionShowId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
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
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record MediaCollectionViewModel(int Id, string Name) : MediaCardViewModel(
|
||||
Id,
|
||||
Name,
|
||||
string.Empty,
|
||||
Name,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
public Collection Collection { get; set; }
|
||||
public int MediaItemId { get; set; }
|
||||
public MediaItem MediaItem { get; set; }
|
||||
public int? CustomIndex { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
.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
|
||||
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>();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Generated
+1626
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
Generated
+1629
File diff suppressed because it is too large
Load Diff
+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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
b.Property<int>("MediaItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("CustomIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CollectionId", "MediaItemId");
|
||||
|
||||
b.HasIndex("MediaItemId");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 @@
|
||||
{
|
||||
<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 @@
|
||||
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 @@
|
||||
|
||||
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 @@
|
||||
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 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnUseCustomOrderChanged()
|
||||
{
|
||||
_data.UseCustomPlaybackOrder = !_data.UseCustomPlaybackOrder;
|
||||
var request = new UpdateCollection(Id, _data.Name) { UseCustomPlaybackOrder = _data.UseCustomPlaybackOrder };
|
||||
await Mediator.Send(request);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,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" : "")"
|
||||
|
||||
Reference in New Issue
Block a user