mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* start to add multi-collections * create multi collection with no items * edit multi collections * fix plex credentials threading issue * add playback order to multi collection items * group episodes outside of shuffled enumerator * move playback order onto each schedule item * fix multi collection grouping * update changelogpull/316/head
87 changed files with 10831 additions and 200 deletions
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands |
||||
{ |
||||
public record CreateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder); |
||||
|
||||
public record CreateMultiCollection |
||||
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>; |
||||
} |
@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Infrastructure.Data; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using static ErsatzTV.Application.MediaCollections.Mapper; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands |
||||
{ |
||||
public class CreateMultiCollectionHandler : |
||||
IRequestHandler<CreateMultiCollection, Either<BaseError, MultiCollectionViewModel>> |
||||
{ |
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||
|
||||
public CreateMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) => |
||||
_dbContextFactory = dbContextFactory; |
||||
|
||||
public async Task<Either<BaseError, MultiCollectionViewModel>> Handle( |
||||
CreateMultiCollection request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request); |
||||
return await validation.Apply(c => PersistCollection(dbContext, c)); |
||||
} |
||||
|
||||
private static async Task<MultiCollectionViewModel> PersistCollection( |
||||
TvContext dbContext, |
||||
MultiCollection multiCollection) |
||||
{ |
||||
await dbContext.MultiCollections.AddAsync(multiCollection); |
||||
await dbContext.SaveChangesAsync(); |
||||
await dbContext.Entry(multiCollection) |
||||
.Collection(c => c.MultiCollectionItems) |
||||
.Query() |
||||
.Include(i => i.Collection) |
||||
.LoadAsync(); |
||||
return ProjectToViewModel(multiCollection); |
||||
} |
||||
|
||||
private static Task<Validation<BaseError, MultiCollection>> Validate( |
||||
TvContext dbContext, |
||||
CreateMultiCollection request) => |
||||
ValidateName(dbContext, request).MapT( |
||||
name => new MultiCollection |
||||
{ |
||||
Name = name, |
||||
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem |
||||
{ |
||||
CollectionId = i.CollectionId, |
||||
ScheduleAsGroup = i.ScheduleAsGroup, |
||||
PlaybackOrder = i.PlaybackOrder |
||||
}).ToList() |
||||
}); |
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName( |
||||
TvContext dbContext, |
||||
CreateMultiCollection createMultiCollection) |
||||
{ |
||||
List<string> allNames = await dbContext.MultiCollections |
||||
.Map(c => c.Name) |
||||
.ToListAsync(); |
||||
|
||||
Validation<BaseError, string> result1 = createMultiCollection.NotEmpty(c => c.Name) |
||||
.Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name)); |
||||
|
||||
var result2 = Optional(createMultiCollection.Name) |
||||
.Filter(name => !allNames.Contains(name)) |
||||
.ToValidation<BaseError>("MultiCollection name must be unique"); |
||||
|
||||
return (result1, result2).Apply((_, _) => createMultiCollection.Name); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands |
||||
{ |
||||
public record DeleteMultiCollection(int MultiCollectionId) : MediatR.IRequest<Either<BaseError, Unit>>; |
||||
} |
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Infrastructure.Data; |
||||
using ErsatzTV.Infrastructure.Extensions; |
||||
using LanguageExt; |
||||
using Microsoft.EntityFrameworkCore; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands |
||||
{ |
||||
public class DeleteMultiCollectionHandler : MediatR.IRequestHandler<DeleteMultiCollection, Either<BaseError, Unit>> |
||||
{ |
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||
|
||||
public DeleteMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) => |
||||
_dbContextFactory = dbContextFactory; |
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle( |
||||
DeleteMultiCollection request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
|
||||
Validation<BaseError, MultiCollection> validation = await MultiCollectionMustExist(dbContext, request); |
||||
return await validation.Apply(c => DoDeletion(dbContext, c)); |
||||
} |
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, MultiCollection multiCollection) |
||||
{ |
||||
dbContext.MultiCollections.Remove(multiCollection); |
||||
return dbContext.SaveChangesAsync().ToUnit(); |
||||
} |
||||
|
||||
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist( |
||||
TvContext dbContext, |
||||
DeleteMultiCollection request) => |
||||
dbContext.MultiCollections |
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.MultiCollectionId) |
||||
.Map(o => o.ToValidation<BaseError>($"MultiCollection {request.MultiCollectionId} does not exist.")); |
||||
} |
||||
} |
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands |
||||
{ |
||||
public record UpdateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder); |
||||
|
||||
public record UpdateMultiCollection |
||||
( |
||||
int MultiCollectionId, |
||||
string Name, |
||||
List<UpdateMultiCollectionItem> Items) : MediatR.IRequest<Either<BaseError, Unit>>; |
||||
} |
@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
|
||||
using System.Collections.Generic; |
||||
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 ErsatzTV.Infrastructure.Data; |
||||
using ErsatzTV.Infrastructure.Extensions; |
||||
using LanguageExt; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands |
||||
{ |
||||
public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiCollection, Either<BaseError, Unit>> |
||||
{ |
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; |
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository; |
||||
|
||||
public UpdateMultiCollectionHandler( |
||||
IDbContextFactory<TvContext> dbContextFactory, |
||||
IMediaCollectionRepository mediaCollectionRepository, |
||||
ChannelWriter<IBackgroundServiceRequest> channel) |
||||
{ |
||||
_dbContextFactory = dbContextFactory; |
||||
_mediaCollectionRepository = mediaCollectionRepository; |
||||
_channel = channel; |
||||
} |
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle( |
||||
UpdateMultiCollection request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request); |
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request)); |
||||
} |
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, MultiCollection c, UpdateMultiCollection request) |
||||
{ |
||||
c.Name = request.Name; |
||||
|
||||
// save name first so playouts don't get rebuild for a name change
|
||||
await dbContext.SaveChangesAsync(); |
||||
|
||||
var toAdd = request.Items |
||||
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId)) |
||||
.Map( |
||||
i => new MultiCollectionItem |
||||
{ |
||||
CollectionId = i.CollectionId, |
||||
MultiCollectionId = c.Id, |
||||
ScheduleAsGroup = i.ScheduleAsGroup, |
||||
PlaybackOrder = i.PlaybackOrder |
||||
}) |
||||
.ToList(); |
||||
var toRemove = c.MultiCollectionItems |
||||
.Filter(i => request.Items.All(i2 => i2.CollectionId != i.CollectionId)) |
||||
.ToList(); |
||||
|
||||
// remove items that are no longer present
|
||||
c.MultiCollectionItems.RemoveAll(toRemove.Contains); |
||||
|
||||
// update existing items
|
||||
foreach (MultiCollectionItem item in c.MultiCollectionItems) |
||||
{ |
||||
foreach (UpdateMultiCollectionItem incoming in request.Items.Filter( |
||||
i => i.CollectionId == item.CollectionId)) |
||||
{ |
||||
item.ScheduleAsGroup = incoming.ScheduleAsGroup; |
||||
item.PlaybackOrder = incoming.PlaybackOrder; |
||||
} |
||||
} |
||||
|
||||
// add new items
|
||||
c.MultiCollectionItems.AddRange(toAdd); |
||||
|
||||
// rebuild playouts
|
||||
if (await dbContext.SaveChangesAsync() > 0) |
||||
{ |
||||
// rebuild all playouts that use this collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection( |
||||
request.MultiCollectionId)) |
||||
{ |
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); |
||||
} |
||||
} |
||||
|
||||
return Unit.Default; |
||||
} |
||||
|
||||
private static async Task<Validation<BaseError, MultiCollection>> Validate( |
||||
TvContext dbContext, |
||||
UpdateMultiCollection request) => |
||||
(await MultiCollectionMustExist(dbContext, request), await ValidateName(dbContext, request)) |
||||
.Apply((collectionToUpdate, _) => collectionToUpdate); |
||||
|
||||
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist( |
||||
TvContext dbContext, |
||||
UpdateMultiCollection updateCollection) => |
||||
dbContext.MultiCollections |
||||
.Include(mc => mc.MultiCollectionItems) |
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId) |
||||
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist.")); |
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(TvContext dbContext, UpdateMultiCollection updateMultiCollection) |
||||
{ |
||||
List<string> allNames = await dbContext.MultiCollections |
||||
.Filter(mc => mc.Id != updateMultiCollection.MultiCollectionId) |
||||
.Map(c => c.Name) |
||||
.ToListAsync(); |
||||
|
||||
Validation<BaseError, string> result1 = updateMultiCollection.NotEmpty(c => c.Name) |
||||
.Bind(_ => updateMultiCollection.NotLongerThan(50)(c => c.Name)); |
||||
|
||||
var result2 = Optional(updateMultiCollection.Name) |
||||
.Filter(name => !allNames.Contains(name)) |
||||
.ToValidation<BaseError>("MultiCollection name must be unique"); |
||||
|
||||
return (result1, result2).Apply((_, _) => updateMultiCollection.Name); |
||||
} |
||||
} |
||||
} |
@ -1,10 +1,25 @@
@@ -1,10 +1,25 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using System.Linq; |
||||
using ErsatzTV.Core.Domain; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections |
||||
{ |
||||
internal static class Mapper |
||||
{ |
||||
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) => |
||||
new(collection.Id, collection.Name); |
||||
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder); |
||||
|
||||
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) => |
||||
new( |
||||
multiCollection.Id, |
||||
multiCollection.Name, |
||||
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList()); |
||||
|
||||
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) => |
||||
new( |
||||
multiCollectionItem.MultiCollectionId, |
||||
ProjectToViewModel(multiCollectionItem.Collection), |
||||
multiCollectionItem.ScheduleAsGroup, |
||||
multiCollectionItem.PlaybackOrder); |
||||
} |
||||
} |
||||
|
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections |
||||
{ |
||||
public record MultiCollectionItemViewModel( |
||||
int MultiCollectionId, |
||||
MediaCollectionViewModel Collection, |
||||
bool ScheduleAsGroup, |
||||
PlaybackOrder PlaybackOrder); |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections |
||||
{ |
||||
public record MultiCollectionViewModel(int Id, string Name, List<MultiCollectionItemViewModel> Items); |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections |
||||
{ |
||||
public record PagedMultiCollectionsViewModel(int TotalCount, List<MultiCollectionViewModel> Page); |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries |
||||
{ |
||||
public record GetAllMultiCollections : IRequest<List<MultiCollectionViewModel>>; |
||||
} |
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Infrastructure.Data; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using static ErsatzTV.Application.MediaCollections.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries |
||||
{ |
||||
public class GetAllMultiCollectionsHandler : IRequestHandler<GetAllMultiCollections, List<MultiCollectionViewModel>> |
||||
{ |
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||
|
||||
public GetAllMultiCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) => |
||||
_dbContextFactory = dbContextFactory; |
||||
|
||||
public async Task<List<MultiCollectionViewModel>> Handle( |
||||
GetAllMultiCollections request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
return await dbContext.MultiCollections |
||||
.ToListAsync(cancellationToken) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using LanguageExt; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries |
||||
{ |
||||
public record GetMultiCollectionById(int Id) : IRequest<Option<MultiCollectionViewModel>>; |
||||
} |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Infrastructure.Data; |
||||
using ErsatzTV.Infrastructure.Extensions; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using static ErsatzTV.Application.MediaCollections.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries |
||||
{ |
||||
public class GetMultiCollectionByIdHandler : IRequestHandler<GetMultiCollectionById, Option<MultiCollectionViewModel>> |
||||
{ |
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||
|
||||
public GetMultiCollectionByIdHandler(IDbContextFactory<TvContext> dbContextFactory) => |
||||
_dbContextFactory = dbContextFactory; |
||||
|
||||
public async Task<Option<MultiCollectionViewModel>> Handle( |
||||
GetMultiCollectionById request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
return await dbContext.MultiCollections |
||||
.Include(mc => mc.MultiCollectionItems) |
||||
.ThenInclude(mc => mc.Collection) |
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id) |
||||
.MapT(ProjectToViewModel); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries |
||||
{ |
||||
public record GetPagedMultiCollections(int PageNum, int PageSize) : IRequest<PagedMultiCollectionsViewModel>; |
||||
} |
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Generic; |
||||
using System.Data; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using Dapper; |
||||
using ErsatzTV.Infrastructure.Data; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using static ErsatzTV.Application.MediaCollections.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries |
||||
{ |
||||
public class GetPagedMultiCollectionsHandler : IRequestHandler<GetPagedMultiCollections, PagedMultiCollectionsViewModel> |
||||
{ |
||||
private readonly IDbConnection _dbConnection; |
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||
|
||||
public GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection) |
||||
{ |
||||
_dbContextFactory = dbContextFactory; |
||||
_dbConnection = dbConnection; |
||||
} |
||||
|
||||
public async Task<PagedMultiCollectionsViewModel> Handle( |
||||
GetPagedMultiCollections request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM Collection"); |
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
List<MultiCollectionViewModel> page = await dbContext.MultiCollections.FromSqlRaw( |
||||
@"SELECT * FROM MultiCollection
|
||||
ORDER BY Name |
||||
COLLATE NOCASE |
||||
LIMIT {0} OFFSET {1}",
|
||||
request.PageSize, |
||||
request.PageNum * request.PageSize) |
||||
.Include(mc => mc.MultiCollectionItems) |
||||
.ThenInclude(i => i.Collection) |
||||
.ToListAsync(cancellationToken) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
|
||||
return new PagedMultiCollectionsViewModel(count, page); |
||||
} |
||||
} |
||||
} |
@ -1,11 +1,8 @@
@@ -1,11 +1,8 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules |
||||
namespace ErsatzTV.Application.ProgramSchedules |
||||
{ |
||||
public record ProgramScheduleViewModel( |
||||
int Id, |
||||
string Name, |
||||
PlaybackOrder MediaCollectionPlaybackOrder, |
||||
bool KeepMultiPartEpisodesTogether, |
||||
bool TreatCollectionsAsShows); |
||||
} |
||||
|
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class MultiCollection |
||||
{ |
||||
public int Id { get; set; } |
||||
public string Name { get; set; } |
||||
public List<Collection> Collections { get; set; } |
||||
public List<MultiCollectionItem> MultiCollectionItems { get; set; } |
||||
} |
||||
} |
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class MultiCollectionItem |
||||
{ |
||||
public int MultiCollectionId { get; set; } |
||||
public MultiCollection MultiCollection { get; set; } |
||||
public int CollectionId { get; set; } |
||||
public Collection Collection { get; set; } |
||||
public bool ScheduleAsGroup { get; set; } |
||||
public PlaybackOrder PlaybackOrder { get; set; } |
||||
} |
||||
} |
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Core.Scheduling |
||||
{ |
||||
public record CollectionWithItems( |
||||
int CollectionId, |
||||
List<MediaItem> MediaItems, |
||||
bool ScheduleAsGroup, |
||||
PlaybackOrder PlaybackOrder, |
||||
bool UseCustomOrder); |
||||
} |
@ -1,9 +1,40 @@
@@ -1,9 +1,40 @@
|
||||
using System.Collections.Generic; |
||||
using System.Diagnostics; |
||||
using ErsatzTV.Core.Domain; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Core.Scheduling |
||||
{ |
||||
[DebuggerDisplay("{First}")] |
||||
public record GroupedMediaItem(MediaItem First, List<MediaItem> Additional); |
||||
[DebuggerDisplay("{" + nameof(First) + "}")] |
||||
public class GroupedMediaItem |
||||
{ |
||||
public GroupedMediaItem() |
||||
{ |
||||
} |
||||
|
||||
public GroupedMediaItem(MediaItem first, List<MediaItem> additional) |
||||
{ |
||||
First = first; |
||||
Additional = additional ?? new List<MediaItem>(); |
||||
} |
||||
|
||||
public MediaItem First { get; set; } |
||||
public List<MediaItem> Additional { get; set; } |
||||
|
||||
public static IList<MediaItem> FlattenGroups(GroupedMediaItem[] copy, int mediaItemCount) |
||||
{ |
||||
var result = new MediaItem[mediaItemCount]; |
||||
var i = 0; |
||||
foreach (GroupedMediaItem group in copy) |
||||
{ |
||||
result[i++] = group.First; |
||||
foreach (MediaItem additional in Optional(group.Additional).Flatten()) |
||||
{ |
||||
result[i++] = additional; |
||||
} |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
} |
||||
} |
||||
|
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
using System; |
||||
using System.Linq; |
||||
using ErsatzTV.Core.Domain; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Core.Scheduling |
||||
{ |
||||
public class MultiCollectionGroup : GroupedMediaItem |
||||
{ |
||||
public MultiCollectionGroup(CollectionWithItems collectionWithItems) |
||||
{ |
||||
if (collectionWithItems.UseCustomOrder) |
||||
{ |
||||
if (collectionWithItems.MediaItems.Count > 0) |
||||
{ |
||||
First = collectionWithItems.MediaItems.Head(); |
||||
Additional = collectionWithItems.MediaItems.Tail().ToList(); |
||||
} |
||||
else |
||||
{ |
||||
throw new InvalidOperationException("Collection has no items"); |
||||
} |
||||
} |
||||
else |
||||
{ |
||||
switch (collectionWithItems.PlaybackOrder) |
||||
{ |
||||
case PlaybackOrder.Chronological: |
||||
var sortedItems = collectionWithItems.MediaItems.OrderBy(identity, new ChronologicalMediaComparer()) |
||||
.ToList(); |
||||
First = sortedItems.Head(); |
||||
Additional = sortedItems.Tail().ToList(); |
||||
break; |
||||
default: |
||||
throw new NotSupportedException( |
||||
$"Unsupported MultiCollection PlaybackOrder: {collectionWithItems.PlaybackOrder}"); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Core.Scheduling |
||||
{ |
||||
public class MultiCollectionGrouper |
||||
{ |
||||
public static List<GroupedMediaItem> GroupMediaItems(IList<CollectionWithItems> collections) |
||||
{ |
||||
var result = new List<GroupedMediaItem>(); |
||||
|
||||
foreach (CollectionWithItems collection in collections) |
||||
{ |
||||
if (collection.ScheduleAsGroup) |
||||
{ |
||||
result.Add(new MultiCollectionGroup(collection)); |
||||
} |
||||
else |
||||
{ |
||||
result.AddRange(collection.MediaItems.Map(i => new GroupedMediaItem { First = i })); |
||||
} |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollection> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<MultiCollection> builder) |
||||
{ |
||||
builder.ToTable("MultiCollection"); |
||||
|
||||
builder.HasMany(m => m.Collections) |
||||
.WithMany(m => m.MultiCollections) |
||||
.UsingEntity<MultiCollectionItem>( |
||||
j => j.HasOne(mci => mci.Collection) |
||||
.WithMany(c => c.MultiCollectionItems) |
||||
.HasForeignKey(mci => mci.CollectionId) |
||||
.OnDelete(DeleteBehavior.Cascade), |
||||
j => j.HasOne(mci => mci.MultiCollection) |
||||
.WithMany(mc => mc.MultiCollectionItems) |
||||
.HasForeignKey(mci => mci.MultiCollectionId) |
||||
.OnDelete(DeleteBehavior.Cascade), |
||||
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId })); |
||||
} |
||||
} |
||||
} |
@ -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 MultiCollectionItemConfiguration : IEntityTypeConfiguration<MultiCollectionItem> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<MultiCollectionItem> builder) => builder.ToTable("MultiCollectionItem"); |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations |
||||
{ |
||||
public partial class Add_MultiCollection : Migration |
||||
{ |
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.AddColumn<int>( |
||||
name: "MultiCollectionId", |
||||
table: "ProgramScheduleItem", |
||||
type: "INTEGER", |
||||
nullable: true); |
||||
|
||||
migrationBuilder.AddColumn<int>( |
||||
name: "MultiCollectionId", |
||||
table: "PlayoutProgramScheduleAnchor", |
||||
type: "INTEGER", |
||||
nullable: true); |
||||
|
||||
migrationBuilder.CreateTable( |
||||
name: "MultiCollection", |
||||
columns: table => new |
||||
{ |
||||
Id = table.Column<int>(type: "INTEGER", nullable: false) |
||||
.Annotation("Sqlite:Autoincrement", true), |
||||
Name = table.Column<string>(type: "TEXT", nullable: true) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_MultiCollection", x => x.Id); |
||||
}); |
||||
|
||||
migrationBuilder.CreateTable( |
||||
name: "MultiCollectionItem", |
||||
columns: table => new |
||||
{ |
||||
MultiCollectionId = table.Column<int>(type: "INTEGER", nullable: false), |
||||
CollectionId = table.Column<int>(type: "INTEGER", nullable: false), |
||||
ScheduleAsGroup = table.Column<bool>(type: "INTEGER", nullable: false), |
||||
PlaybackOrder = table.Column<int>(type: "INTEGER", nullable: false) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_MultiCollectionItem", x => new { x.MultiCollectionId, x.CollectionId }); |
||||
table.ForeignKey( |
||||
name: "FK_MultiCollectionItem_Collection_CollectionId", |
||||
column: x => x.CollectionId, |
||||
principalTable: "Collection", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
table.ForeignKey( |
||||
name: "FK_MultiCollectionItem_MultiCollection_MultiCollectionId", |
||||
column: x => x.MultiCollectionId, |
||||
principalTable: "MultiCollection", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
}); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
name: "IX_ProgramScheduleItem_MultiCollectionId", |
||||
table: "ProgramScheduleItem", |
||||
column: "MultiCollectionId"); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
name: "IX_PlayoutProgramScheduleAnchor_MultiCollectionId", |
||||
table: "PlayoutProgramScheduleAnchor", |
||||
column: "MultiCollectionId"); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
name: "IX_MultiCollectionItem_CollectionId", |
||||
table: "MultiCollectionItem", |
||||
column: "CollectionId"); |
||||
|
||||
migrationBuilder.AddForeignKey( |
||||
name: "FK_PlayoutProgramScheduleAnchor_MultiCollection_MultiCollectionId", |
||||
table: "PlayoutProgramScheduleAnchor", |
||||
column: "MultiCollectionId", |
||||
principalTable: "MultiCollection", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
|
||||
migrationBuilder.AddForeignKey( |
||||
name: "FK_ProgramScheduleItem_MultiCollection_MultiCollectionId", |
||||
table: "ProgramScheduleItem", |
||||
column: "MultiCollectionId", |
||||
principalTable: "MultiCollection", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
} |
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.DropForeignKey( |
||||
name: "FK_PlayoutProgramScheduleAnchor_MultiCollection_MultiCollectionId", |
||||
table: "PlayoutProgramScheduleAnchor"); |
||||
|
||||
migrationBuilder.DropForeignKey( |
||||
name: "FK_ProgramScheduleItem_MultiCollection_MultiCollectionId", |
||||
table: "ProgramScheduleItem"); |
||||
|
||||
migrationBuilder.DropTable( |
||||
name: "MultiCollectionItem"); |
||||
|
||||
migrationBuilder.DropTable( |
||||
name: "MultiCollection"); |
||||
|
||||
migrationBuilder.DropIndex( |
||||
name: "IX_ProgramScheduleItem_MultiCollectionId", |
||||
table: "ProgramScheduleItem"); |
||||
|
||||
migrationBuilder.DropIndex( |
||||
name: "IX_PlayoutProgramScheduleAnchor_MultiCollectionId", |
||||
table: "PlayoutProgramScheduleAnchor"); |
||||
|
||||
migrationBuilder.DropColumn( |
||||
name: "MultiCollectionId", |
||||
table: "ProgramScheduleItem"); |
||||
|
||||
migrationBuilder.DropColumn( |
||||
name: "MultiCollectionId", |
||||
table: "PlayoutProgramScheduleAnchor"); |
||||
} |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations |
||||
{ |
||||
public partial class Add_ProgramScheduleItemPlaybackOrder : Migration |
||||
{ |
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.AddColumn<int>( |
||||
name: "PlaybackOrder", |
||||
table: "ProgramScheduleItem", |
||||
type: "INTEGER", |
||||
nullable: false, |
||||
defaultValue: 0); |
||||
|
||||
migrationBuilder.Sql( |
||||
"UPDATE ProgramScheduleItem SET PlaybackOrder = (SELECT MediaCollectionPlaybackOrder FROM ProgramSchedule WHERE ProgramSchedule.Id = ProgramScheduleItem.ProgramScheduleId)"); |
||||
} |
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.DropColumn( |
||||
name: "PlaybackOrder", |
||||
table: "ProgramScheduleItem"); |
||||
} |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations |
||||
{ |
||||
public partial class Remove_ProgramScheduleMediaCollectionPlaybackOrder : Migration |
||||
{ |
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.DropColumn( |
||||
name: "MediaCollectionPlaybackOrder", |
||||
table: "ProgramSchedule"); |
||||
} |
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.AddColumn<int>( |
||||
name: "MediaCollectionPlaybackOrder", |
||||
table: "ProgramSchedule", |
||||
type: "INTEGER", |
||||
nullable: false, |
||||
defaultValue: 0); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
|
||||
@page "/media/multi-collections/{Id:int}/edit" |
||||
@page "/media/multi-collections/add" |
||||
@using ErsatzTV.Application.MediaCollections |
||||
@using ErsatzTV.Application.MediaCollections.Commands |
||||
@using ErsatzTV.Application.MediaCollections.Queries |
||||
@inject IMediator _mediator |
||||
@inject NavigationManager _navigationManager |
||||
@inject ISnackbar _snackbar |
||||
@inject ILogger<MultiCollectionEditor> _logger |
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> |
||||
<div style="max-width: 400px;"> |
||||
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Multi Collection" : "Add Multi Collection")</MudText> |
||||
|
||||
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync"> |
||||
<FluentValidator/> |
||||
<MudCard> |
||||
<MudCardContent> |
||||
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/> |
||||
<MudAutocomplete @ref="_collectionAutocomplete" |
||||
Class="mt-4" |
||||
T="MediaCollectionViewModel" |
||||
Label="Collection" |
||||
@bind-value="_selectedCollection" |
||||
SearchFunc="@SearchCollections" |
||||
ToStringFunc="@(c => c?.Name)"/> |
||||
</MudCardContent> |
||||
<MudCardActions> |
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddCollection())" Class="ml-2"> |
||||
Add Collection |
||||
</MudButton> |
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="mr-2 ml-auto"> |
||||
@(IsEdit ? "Save Changes" : "Add Multi Collection") |
||||
</MudButton> |
||||
</MudCardActions> |
||||
</MudCard> |
||||
</EditForm> |
||||
</div> |
||||
|
||||
<MudTable Hover="true" Items="_model.Items.OrderBy(i => i.Collection.Name, StringComparer.CurrentCultureIgnoreCase)" Dense="true" Class="mt-6"> |
||||
<ToolBarContent> |
||||
<MudText Typo="Typo.h6">@_model.Name Items</MudText> |
||||
</ToolBarContent> |
||||
<ColGroup> |
||||
<col/> |
||||
<col style="width: 20%"/> |
||||
<col style="width: 30%"/> |
||||
<col style="width: 60px;"/> |
||||
</ColGroup> |
||||
<HeaderContent> |
||||
<MudTh>Collection</MudTh> |
||||
<MudTh>Schedule As Group</MudTh> |
||||
<MudTh>Playback Order</MudTh> |
||||
<MudTh/> |
||||
</HeaderContent> |
||||
<RowTemplate> |
||||
<MudTd DataLabel="Collection"> |
||||
<MudText Typo="Typo.body2"> |
||||
@context.Collection.Name |
||||
</MudText> |
||||
</MudTd> |
||||
<MudTd DataLabel="Schedule As Group"> |
||||
<MudCheckBox @bind-Checked="@context.ScheduleAsGroup" For="@(() => context.ScheduleAsGroup)"/> |
||||
</MudTd> |
||||
<MudTd DataLabel="Playback Order"> |
||||
@if (context.ScheduleAsGroup) |
||||
{ |
||||
<MudText Typo="Typo.body2"> |
||||
@(context.Collection.UseCustomPlaybackOrder ? "Custom" : "Chronological") |
||||
</MudText> |
||||
} |
||||
</MudTd> |
||||
<MudTd> |
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" |
||||
OnClick="@(_ => RemoveCollection(context))"> |
||||
</MudIconButton> |
||||
</MudTd> |
||||
</RowTemplate> |
||||
</MudTable> |
||||
</MudContainer> |
||||
|
||||
@code { |
||||
[Parameter] |
||||
public int Id { get; set; } |
||||
|
||||
private readonly MultiCollectionEditViewModel _model = new(); |
||||
private EditContext _editContext; |
||||
private ValidationMessageStore _messageStore; |
||||
private List<MediaCollectionViewModel> _collections; |
||||
private MediaCollectionViewModel _selectedCollection; |
||||
private MudAutocomplete<MediaCollectionViewModel> _collectionAutocomplete; |
||||
|
||||
protected override async Task OnParametersSetAsync() |
||||
{ |
||||
_collections = await _mediator.Send(new GetAllCollections()) |
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); |
||||
|
||||
if (IsEdit) |
||||
{ |
||||
Option<MultiCollectionViewModel> maybeCollection = await _mediator.Send(new GetMultiCollectionById(Id)); |
||||
maybeCollection.IfSome(collection => |
||||
{ |
||||
_model.Id = collection.Id; |
||||
_model.Name = collection.Name; |
||||
_model.Items = collection.Items.Map(item => |
||||
new MultiCollectionItemEditViewModel |
||||
{ |
||||
Collection = item.Collection, |
||||
ScheduleAsGroup = item.ScheduleAsGroup, |
||||
PlaybackOrder = item.PlaybackOrder, |
||||
}).ToList(); |
||||
}); |
||||
} |
||||
else |
||||
{ |
||||
_model.Name = "New Multi Collection"; |
||||
_model.Items = new List<MultiCollectionItemEditViewModel>(); |
||||
} |
||||
} |
||||
|
||||
protected override void OnInitialized() |
||||
{ |
||||
_editContext = new EditContext(_model); |
||||
_messageStore = new ValidationMessageStore(_editContext); |
||||
} |
||||
|
||||
private bool IsEdit => Id != 0; |
||||
|
||||
private async Task HandleSubmitAsync() |
||||
{ |
||||
_messageStore.Clear(); |
||||
if (_editContext.Validate()) |
||||
{ |
||||
Seq<BaseError> errorMessage = IsEdit ? |
||||
(await _mediator.Send(new UpdateMultiCollection(Id, _model.Name, _model.Items.Map(i => new UpdateMultiCollectionItem(i.Collection.Id, i.ScheduleAsGroup, i.PlaybackOrder)).ToList()))).LeftToSeq() : |
||||
(await _mediator.Send(new CreateMultiCollection(_model.Name, _model.Items.Map(i => new CreateMultiCollectionItem(i.Collection.Id, i.ScheduleAsGroup, i.PlaybackOrder)).ToList()))).LeftToSeq(); |
||||
|
||||
errorMessage.HeadOrNone().Match( |
||||
error => |
||||
{ |
||||
_snackbar.Add(error.Value, Severity.Error); |
||||
_logger.LogError("Error saving collection: {Error}", error.Value); |
||||
}, |
||||
() => _navigationManager.NavigateTo("/media/collections")); |
||||
} |
||||
} |
||||
|
||||
private void RemoveCollection(MultiCollectionItemEditViewModel item) |
||||
{ |
||||
_model.Items.Remove(item); |
||||
} |
||||
|
||||
private void AddCollection() |
||||
{ |
||||
if (_selectedCollection != null && _model.Items.All(i => i.Collection != _selectedCollection)) |
||||
{ |
||||
_model.Items.Add(new MultiCollectionItemEditViewModel |
||||
{ |
||||
Collection = _selectedCollection, |
||||
PlaybackOrder = PlaybackOrder.Chronological |
||||
}); |
||||
|
||||
_selectedCollection = null; |
||||
_collectionAutocomplete.Reset(); |
||||
} |
||||
} |
||||
|
||||
private Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value) => |
||||
_collections.Filter(c => _model.Items.All(i => i.Collection != c) && c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask(); |
||||
|
||||
} |
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.ViewModels; |
||||
using FluentValidation; |
||||
|
||||
namespace ErsatzTV.Validators |
||||
{ |
||||
public class CollectionEditViewModelValidator : AbstractValidator<CollectionEditViewModel> |
||||
{ |
||||
public CollectionEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty(); |
||||
} |
||||
} |
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.ViewModels; |
||||
using FluentValidation; |
||||
|
||||
namespace ErsatzTV.Validators |
||||
{ |
||||
public class MultiCollectionEditViewModelValidator : AbstractValidator<MultiCollectionEditViewModel> |
||||
{ |
||||
public MultiCollectionEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty(); |
||||
} |
||||
} |
@ -1,10 +0,0 @@
@@ -1,10 +0,0 @@
|
||||
using ErsatzTV.ViewModels; |
||||
using FluentValidation; |
||||
|
||||
namespace ErsatzTV.Validators |
||||
{ |
||||
public class SimpleMediaCollectionEditViewModelValidator : AbstractValidator<SimpleMediaCollectionEditViewModel> |
||||
{ |
||||
public SimpleMediaCollectionEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty(); |
||||
} |
||||
} |
@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
|
||||
namespace ErsatzTV.ViewModels |
||||
{ |
||||
public class SimpleMediaCollectionEditViewModel |
||||
public class CollectionEditViewModel |
||||
{ |
||||
public int Id { get; set; } |
||||
public string Name { get; set; } |
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.ViewModels |
||||
{ |
||||
public class MultiCollectionEditViewModel |
||||
{ |
||||
public int Id { get; set; } |
||||
public string Name { get; set; } |
||||
public List<MultiCollectionItemEditViewModel> Items { get; set; } |
||||
} |
||||
} |
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.Application.MediaCollections; |
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.ViewModels |
||||
{ |
||||
public class MultiCollectionItemEditViewModel |
||||
{ |
||||
public MediaCollectionViewModel Collection { get; set; } |
||||
public bool ScheduleAsGroup { get; set; } |
||||
public PlaybackOrder PlaybackOrder { get; set; } |
||||
} |
||||
} |
Loading…
Reference in new issue