Browse Source

add multi collections (#315)

* 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 changelog
pull/316/head
Jason Dove 4 years ago committed by GitHub
parent
commit
632753ea93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      CHANGELOG.md
  2. 13
      ErsatzTV.Application/MediaCollections/Commands/CreateMultiCollection.cs
  3. 80
      ErsatzTV.Application/MediaCollections/Commands/CreateMultiCollectionHandler.cs
  4. 7
      ErsatzTV.Application/MediaCollections/Commands/DeleteMultiCollection.cs
  5. 42
      ErsatzTV.Application/MediaCollections/Commands/DeleteMultiCollectionHandler.cs
  6. 15
      ErsatzTV.Application/MediaCollections/Commands/UpdateMultiCollection.cs
  7. 127
      ErsatzTV.Application/MediaCollections/Commands/UpdateMultiCollectionHandler.cs
  8. 19
      ErsatzTV.Application/MediaCollections/Mapper.cs
  9. 2
      ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs
  10. 10
      ErsatzTV.Application/MediaCollections/MultiCollectionItemViewModel.cs
  11. 6
      ErsatzTV.Application/MediaCollections/MultiCollectionViewModel.cs
  12. 6
      ErsatzTV.Application/MediaCollections/PagedMultiCollectionsViewModel.cs
  13. 7
      ErsatzTV.Application/MediaCollections/Queries/GetAllMultiCollections.cs
  14. 30
      ErsatzTV.Application/MediaCollections/Queries/GetAllMultiCollectionsHandler.cs
  15. 7
      ErsatzTV.Application/MediaCollections/Queries/GetMultiCollectionById.cs
  16. 31
      ErsatzTV.Application/MediaCollections/Queries/GetMultiCollectionByIdHandler.cs
  17. 6
      ErsatzTV.Application/MediaCollections/Queries/GetPagedMultiCollections.cs
  18. 48
      ErsatzTV.Application/MediaCollections/Queries/GetPagedMultiCollectionsHandler.cs
  19. 2
      ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs
  20. 1
      ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs
  21. 5
      ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs
  22. 2
      ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs
  23. 15
      ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs
  24. 2
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs
  25. 42
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs
  26. 1
      ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs
  27. 6
      ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs
  28. 17
      ErsatzTV.Application/ProgramSchedules/Mapper.cs
  29. 4
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs
  30. 4
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs
  31. 4
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs
  32. 4
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs
  33. 4
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs
  34. 5
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs
  35. 1
      ErsatzTV.Application/ProgramSchedules/Queries/GetAllProgramSchedulesHandler.cs
  36. 1
      ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs
  37. 10
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  38. 90
      ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs
  39. 18
      ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs
  40. 2
      ErsatzTV.Core/Domain/Collection/Collection.cs
  41. 12
      ErsatzTV.Core/Domain/Collection/MultiCollection.cs
  42. 12
      ErsatzTV.Core/Domain/Collection/MultiCollectionItem.cs
  43. 1
      ErsatzTV.Core/Domain/ConfigElementKey.cs
  44. 2
      ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs
  45. 1
      ErsatzTV.Core/Domain/ProgramSchedule.cs
  46. 3
      ErsatzTV.Core/Domain/ProgramScheduleItem.cs
  47. 3
      ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs
  48. 4
      ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs
  49. 12
      ErsatzTV.Core/Scheduling/CollectionWithItems.cs
  50. 35
      ErsatzTV.Core/Scheduling/GroupedMediaItem.cs
  51. 41
      ErsatzTV.Core/Scheduling/MultiCollectionGroup.cs
  52. 26
      ErsatzTV.Core/Scheduling/MultiCollectionGrouper.cs
  53. 18
      ErsatzTV.Core/Scheduling/MultiPartEpisodeGrouper.cs
  54. 49
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  55. 15
      ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs
  56. 27
      ErsatzTV.Infrastructure/Data/Configurations/Collection/MultiCollectionConfiguration.cs
  57. 11
      ErsatzTV.Infrastructure/Data/Configurations/Collection/MultiCollectionItemConfiguration.cs
  58. 6
      ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs
  59. 6
      ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs
  60. 109
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  61. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  62. 3064
      ErsatzTV.Infrastructure/Migrations/20210717172758_Add_MultiCollection.Designer.cs
  63. 125
      ErsatzTV.Infrastructure/Migrations/20210717172758_Add_MultiCollection.cs
  64. 3067
      ErsatzTV.Infrastructure/Migrations/20210717190207_Add_ProgramScheduleItemPlaybackOrder.Designer.cs
  65. 27
      ErsatzTV.Infrastructure/Migrations/20210717190207_Add_ProgramScheduleItemPlaybackOrder.cs
  66. 3064
      ErsatzTV.Infrastructure/Migrations/20210717195354_Remove_ProgramScheduleMediaCollectionPlaybackOrder.Designer.cs
  67. 24
      ErsatzTV.Infrastructure/Migrations/20210717195354_Remove_ProgramScheduleMediaCollectionPlaybackOrder.cs
  68. 93
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  69. 58
      ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs
  70. 2
      ErsatzTV/Pages/Artist.razor
  71. 2
      ErsatzTV/Pages/CollectionEditor.razor
  72. 118
      ErsatzTV/Pages/Collections.razor
  73. 171
      ErsatzTV/Pages/MultiCollectionEditor.razor
  74. 9
      ErsatzTV/Pages/ScheduleEditor.razor
  75. 26
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  76. 3
      ErsatzTV/Pages/Schedules.razor
  77. 2
      ErsatzTV/Pages/TelevisionEpisodeList.razor
  78. 2
      ErsatzTV/Pages/TelevisionSeasonList.razor
  79. 2
      ErsatzTV/Shared/AddToCollectionDialog.razor
  80. 10
      ErsatzTV/Validators/CollectionEditViewModelValidator.cs
  81. 10
      ErsatzTV/Validators/MultiCollectionEditViewModelValidator.cs
  82. 10
      ErsatzTV/Validators/SimpleMediaCollectionEditViewModelValidator.cs
  83. 2
      ErsatzTV/ViewModels/CollectionEditViewModel.cs
  84. 11
      ErsatzTV/ViewModels/MultiCollectionEditViewModel.cs
  85. 12
      ErsatzTV/ViewModels/MultiCollectionItemEditViewModel.cs
  86. 5
      ErsatzTV/ViewModels/ProgramScheduleEditViewModel.cs
  87. 11
      ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

9
CHANGELOG.md

@ -4,9 +4,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added
- Add `Multi Collection` to support shuffling multiple collections within a single schedule item
- Collections within a multi collection are optionally grouped together and ordered when scheduling; this can be useful for franchises
### Changed
- Move `Playback Order` from schedule to schedule items
- This allows different schedule items to have different playback orders within a single schedule
### Fixed ### Fixed
- Fix release notes on home page with `-alpha` suffix - Fix release notes on home page with `-alpha` suffix
- Fix linux-arm release by including SQLite interop artifacts - Fix linux-arm release by including SQLite interop artifacts
- Fix issue where cached Plex credentials may become invalid when multiple servers are used
## [0.0.50-alpha] - 2021-07-13 ## [0.0.50-alpha] - 2021-07-13
### Added ### Added

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

@ -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>>;
}

80
ErsatzTV.Application/MediaCollections/Commands/CreateMultiCollectionHandler.cs

@ -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);
}
}
}

7
ErsatzTV.Application/MediaCollections/Commands/DeleteMultiCollection.cs

@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record DeleteMultiCollection(int MultiCollectionId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

42
ErsatzTV.Application/MediaCollections/Commands/DeleteMultiCollectionHandler.cs

@ -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."));
}
}

15
ErsatzTV.Application/MediaCollections/Commands/UpdateMultiCollection.cs

@ -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>>;
}

127
ErsatzTV.Application/MediaCollections/Commands/UpdateMultiCollectionHandler.cs

@ -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);
}
}
}

19
ErsatzTV.Application/MediaCollections/Mapper.cs

@ -1,10 +1,25 @@
using ErsatzTV.Core.Domain; using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections namespace ErsatzTV.Application.MediaCollections
{ {
internal static class Mapper internal static class Mapper
{ {
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) => 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);
} }
} }

2
ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs

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

10
ErsatzTV.Application/MediaCollections/MultiCollectionItemViewModel.cs

@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionItemViewModel(
int MultiCollectionId,
MediaCollectionViewModel Collection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
}

6
ErsatzTV.Application/MediaCollections/MultiCollectionViewModel.cs

@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionViewModel(int Id, string Name, List<MultiCollectionItemViewModel> Items);
}

6
ErsatzTV.Application/MediaCollections/PagedMultiCollectionsViewModel.cs

@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedMultiCollectionsViewModel(int TotalCount, List<MultiCollectionViewModel> Page);
}

7
ErsatzTV.Application/MediaCollections/Queries/GetAllMultiCollections.cs

@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetAllMultiCollections : IRequest<List<MultiCollectionViewModel>>;
}

30
ErsatzTV.Application/MediaCollections/Queries/GetAllMultiCollectionsHandler.cs

@ -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());
}
}
}

7
ErsatzTV.Application/MediaCollections/Queries/GetMultiCollectionById.cs

@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetMultiCollectionById(int Id) : IRequest<Option<MultiCollectionViewModel>>;
}

31
ErsatzTV.Application/MediaCollections/Queries/GetMultiCollectionByIdHandler.cs

@ -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);
}
}
}

6
ErsatzTV.Application/MediaCollections/Queries/GetPagedMultiCollections.cs

@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedMultiCollections(int PageNum, int PageSize) : IRequest<PagedMultiCollectionsViewModel>;
}

48
ErsatzTV.Application/MediaCollections/Queries/GetPagedMultiCollectionsHandler.cs

@ -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);
}
}
}

2
ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs

@ -13,7 +13,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlayoutMode PlayoutMode, PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType, ProgramScheduleItemCollectionType CollectionType,
int? CollectionId, int? CollectionId,
int? MultiCollectionId,
int? MediaItemId, int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount, int? MultipleCount,
TimeSpan? PlayoutDuration, TimeSpan? PlayoutDuration,
bool? OfflineTail, bool? OfflineTail,

1
ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs

@ -7,7 +7,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
{ {
public record CreateProgramSchedule( public record CreateProgramSchedule(
string Name, string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether, bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, CreateProgramScheduleResult>>; bool TreatCollectionsAsShows) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
} }

5
ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs

@ -45,13 +45,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ValidateName(dbContext, request), ValidateName(dbContext, request),
name => name =>
{ {
bool keepMultiPartEpisodesTogether = bool keepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
request.KeepMultiPartEpisodesTogether;
return new ProgramSchedule return new ProgramSchedule
{ {
Name = name, Name = name,
MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder,
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether, KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows
}; };

2
ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs

@ -8,8 +8,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
TimeSpan? StartTime { get; } TimeSpan? StartTime { get; }
ProgramScheduleItemCollectionType CollectionType { get; } ProgramScheduleItemCollectionType CollectionType { get; }
int? CollectionId { get; } int? CollectionId { get; }
int? MultiCollectionId { get; }
int? MediaItemId { get; } int? MediaItemId { get; }
PlayoutMode PlayoutMode { get; } PlayoutMode PlayoutMode { get; }
PlaybackOrder PlaybackOrder { get; }
int? MultipleCount { get; } int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; } TimeSpan? PlayoutDuration { get; }
bool? OfflineTail { get; } bool? OfflineTail { get; }

15
ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs

@ -88,6 +88,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[MediaItem] is required for collection type 'Artist'"); return BaseError.New("[MediaItem] is required for collection type 'Artist'");
} }
break;
case ProgramScheduleItemCollectionType.MultiCollection:
if (item.MultiCollectionId is null)
{
return BaseError.New("[MultiCollection] is required for collection type 'MultiCollection'");
}
break; break;
default: default:
return BaseError.New("[CollectionType] is invalid"); return BaseError.New("[CollectionType] is invalid");
@ -109,7 +116,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime, StartTime = item.StartTime,
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
MediaItemId = item.MediaItemId, MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle CustomTitle = item.CustomTitle
}, },
PlayoutMode.One => new ProgramScheduleItemOne PlayoutMode.One => new ProgramScheduleItemOne
@ -119,7 +128,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime, StartTime = item.StartTime,
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
MediaItemId = item.MediaItemId, MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle CustomTitle = item.CustomTitle
}, },
PlayoutMode.Multiple => new ProgramScheduleItemMultiple PlayoutMode.Multiple => new ProgramScheduleItemMultiple
@ -129,7 +140,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime, StartTime = item.StartTime,
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
MediaItemId = item.MediaItemId, MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
Count = item.MultipleCount.GetValueOrDefault(), Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle CustomTitle = item.CustomTitle
}, },
@ -140,7 +153,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime, StartTime = item.StartTime,
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
CollectionId = item.CollectionId, CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
MediaItemId = item.MediaItemId, MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()), PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
OfflineTail = item.OfflineTail.GetValueOrDefault(), OfflineTail = item.OfflineTail.GetValueOrDefault(),
CustomTitle = item.CustomTitle CustomTitle = item.CustomTitle

2
ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs

@ -14,7 +14,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlayoutMode PlayoutMode, PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType, ProgramScheduleItemCollectionType CollectionType,
int? CollectionId, int? CollectionId,
int? MultiCollectionId,
int? MediaItemId, int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount, int? MultipleCount,
TimeSpan? PlayoutDuration, TimeSpan? PlayoutDuration,
bool? OfflineTail, bool? OfflineTail,

42
ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs

@ -11,6 +11,7 @@ using LanguageExt;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.ProgramSchedules.Mapper; using static ErsatzTV.Application.ProgramSchedules.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.ProgramSchedules.Commands namespace ErsatzTV.Application.ProgramSchedules.Commands
{ {
@ -61,7 +62,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ReplaceProgramScheduleItems request) => ReplaceProgramScheduleItems request) =>
ProgramScheduleMustExist(dbContext, request.ProgramScheduleId) ProgramScheduleMustExist(dbContext, request.ProgramScheduleId)
.BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule)) .BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule))
.BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule)); .BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule))
.BindT(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule));
private static Validation<BaseError, ProgramSchedule> PlayoutModesMustBeValid( private static Validation<BaseError, ProgramSchedule> PlayoutModesMustBeValid(
ReplaceProgramScheduleItems request, ReplaceProgramScheduleItems request,
@ -74,5 +76,43 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramSchedule programSchedule) => ProgramSchedule programSchedule) =>
request.Items.Map(item => CollectionTypeMustBeValid(item, programSchedule)).Sequence() request.Items.Map(item => CollectionTypeMustBeValid(item, programSchedule)).Sequence()
.Map(_ => programSchedule); .Map(_ => programSchedule);
private static Validation<BaseError, ProgramSchedule> PlaybackOrdersMustBeValid(
ReplaceProgramScheduleItems request,
ProgramSchedule programSchedule)
{
var keyOrders = new Dictionary<CollectionKey, System.Collections.Generic.HashSet<PlaybackOrder>>();
foreach (ReplaceProgramScheduleItem item in request.Items)
{
var key = new CollectionKey(
item.CollectionType,
item.CollectionId,
item.MediaItemId,
item.MultiCollectionId);
if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet<PlaybackOrder> playbackOrders))
{
playbackOrders.Add(item.PlaybackOrder);
keyOrders[key] = playbackOrders;
}
else
{
keyOrders.Add(key, new System.Collections.Generic.HashSet<PlaybackOrder> { item.PlaybackOrder });
}
}
return Optional(keyOrders.Values.Count(set => set.Count != 1))
.Filter(count => count == 0)
.Map(_ => programSchedule)
.ToValidation<BaseError>("A collection must not use multiple playback orders");
}
private record CollectionKey(
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId);
private record CollectionKeyOrder(CollectionKey Key, PlaybackOrder PlaybackOrder);
} }
} }

1
ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs

@ -9,7 +9,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
( (
int ProgramScheduleId, int ProgramScheduleId,
string Name, string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether, bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>; bool TreatCollectionsAsShows) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
} }

6
ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs

@ -44,15 +44,11 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
{ {
// we need to rebuild playouts if the playback order or keep multi-episodes has been modified // we need to rebuild playouts if the playback order or keep multi-episodes has been modified
bool needToRebuildPlayout = bool needToRebuildPlayout =
programSchedule.MediaCollectionPlaybackOrder != request.MediaCollectionPlaybackOrder ||
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether || programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows; programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows;
programSchedule.Name = request.Name; programSchedule.Name = request.Name;
programSchedule.MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder; programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
programSchedule.KeepMultiPartEpisodesTogether =
request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
request.KeepMultiPartEpisodesTogether;
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether && programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
request.TreatCollectionsAsShows; request.TreatCollectionsAsShows;

17
ErsatzTV.Application/ProgramSchedules/Mapper.cs

@ -9,7 +9,6 @@ namespace ErsatzTV.Application.ProgramSchedules
new( new(
programSchedule.Id, programSchedule.Id,
programSchedule.Name, programSchedule.Name,
programSchedule.MediaCollectionPlaybackOrder,
programSchedule.KeepMultiPartEpisodesTogether, programSchedule.KeepMultiPartEpisodesTogether,
programSchedule.TreatCollectionsAsShows); programSchedule.TreatCollectionsAsShows);
@ -26,6 +25,9 @@ namespace ErsatzTV.Application.ProgramSchedules
duration.Collection != null duration.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.Collection) ? MediaCollections.Mapper.ProjectToViewModel(duration.Collection)
: null, : null,
duration.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.MultiCollection)
: null,
duration.MediaItem switch duration.MediaItem switch
{ {
Show show => MediaItems.Mapper.ProjectToViewModel(show), Show show => MediaItems.Mapper.ProjectToViewModel(show),
@ -33,6 +35,7 @@ namespace ErsatzTV.Application.ProgramSchedules
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist), Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null _ => null
}, },
duration.PlaybackOrder,
duration.PlayoutDuration, duration.PlayoutDuration,
duration.OfflineTail, duration.OfflineTail,
duration.CustomTitle), duration.CustomTitle),
@ -46,6 +49,9 @@ namespace ErsatzTV.Application.ProgramSchedules
flood.Collection != null flood.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.Collection) ? MediaCollections.Mapper.ProjectToViewModel(flood.Collection)
: null, : null,
flood.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.MultiCollection)
: null,
flood.MediaItem switch flood.MediaItem switch
{ {
Show show => MediaItems.Mapper.ProjectToViewModel(show), Show show => MediaItems.Mapper.ProjectToViewModel(show),
@ -53,6 +59,7 @@ namespace ErsatzTV.Application.ProgramSchedules
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist), Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null _ => null
}, },
flood.PlaybackOrder,
flood.CustomTitle), flood.CustomTitle),
ProgramScheduleItemMultiple multiple => ProgramScheduleItemMultiple multiple =>
new ProgramScheduleItemMultipleViewModel( new ProgramScheduleItemMultipleViewModel(
@ -64,6 +71,9 @@ namespace ErsatzTV.Application.ProgramSchedules
multiple.Collection != null multiple.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.Collection) ? MediaCollections.Mapper.ProjectToViewModel(multiple.Collection)
: null, : null,
multiple.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.MultiCollection)
: null,
multiple.MediaItem switch multiple.MediaItem switch
{ {
Show show => MediaItems.Mapper.ProjectToViewModel(show), Show show => MediaItems.Mapper.ProjectToViewModel(show),
@ -71,6 +81,7 @@ namespace ErsatzTV.Application.ProgramSchedules
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist), Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null _ => null
}, },
multiple.PlaybackOrder,
multiple.Count, multiple.Count,
multiple.CustomTitle), multiple.CustomTitle),
ProgramScheduleItemOne one => ProgramScheduleItemOne one =>
@ -83,6 +94,9 @@ namespace ErsatzTV.Application.ProgramSchedules
one.Collection != null one.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(one.Collection) ? MediaCollections.Mapper.ProjectToViewModel(one.Collection)
: null, : null,
one.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(one.MultiCollection)
: null,
one.MediaItem switch one.MediaItem switch
{ {
Show show => MediaItems.Mapper.ProjectToViewModel(show), Show show => MediaItems.Mapper.ProjectToViewModel(show),
@ -90,6 +104,7 @@ namespace ErsatzTV.Application.ProgramSchedules
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist), Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null _ => null
}, },
one.PlaybackOrder,
one.CustomTitle), one.CustomTitle),
_ => throw new NotSupportedException( _ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}") $"Unsupported program schedule item type {programScheduleItem.GetType().Name}")

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs

@ -14,7 +14,9 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime, TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType, ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection, MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
NamedMediaItemViewModel mediaItem, NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
TimeSpan playoutDuration, TimeSpan playoutDuration,
bool offlineTail, bool offlineTail,
string customTitle) : base( string customTitle) : base(
@ -25,7 +27,9 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Duration, PlayoutMode.Duration,
collectionType, collectionType,
collection, collection,
multiCollection,
mediaItem, mediaItem,
playbackOrder,
customTitle) customTitle)
{ {
PlayoutDuration = playoutDuration; PlayoutDuration = playoutDuration;

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs

@ -14,7 +14,9 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime, TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType, ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection, MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
NamedMediaItemViewModel mediaItem, NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base( string customTitle) : base(
id, id,
index, index,
@ -23,7 +25,9 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Flood, PlayoutMode.Flood,
collectionType, collectionType,
collection, collection,
multiCollection,
mediaItem, mediaItem,
playbackOrder,
customTitle) customTitle)
{ {
} }

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs

@ -14,7 +14,9 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime, TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType, ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection, MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
NamedMediaItemViewModel mediaItem, NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
int count, int count,
string customTitle) : base( string customTitle) : base(
id, id,
@ -24,7 +26,9 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Multiple, PlayoutMode.Multiple,
collectionType, collectionType,
collection, collection,
multiCollection,
mediaItem, mediaItem,
playbackOrder,
customTitle) => customTitle) =>
Count = count; Count = count;

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs

@ -14,7 +14,9 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime, TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType, ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection, MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
NamedMediaItemViewModel mediaItem, NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base( string customTitle) : base(
id, id,
index, index,
@ -23,7 +25,9 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.One, PlayoutMode.One,
collectionType, collectionType,
collection, collection,
multiCollection,
mediaItem, mediaItem,
playbackOrder,
customTitle) customTitle)
{ {
} }

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs

@ -13,7 +13,9 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode PlayoutMode, PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType, ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection, MediaCollectionViewModel Collection,
MultiCollectionViewModel MultiCollection,
NamedMediaItemViewModel MediaItem, NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
string CustomTitle) string CustomTitle)
{ {
public string Name => CollectionType switch public string Name => CollectionType switch
@ -25,6 +27,8 @@ namespace ErsatzTV.Application.ProgramSchedules
MediaItem?.Name, // $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})", MediaItem?.Name, // $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})",
ProgramScheduleItemCollectionType.Artist => ProgramScheduleItemCollectionType.Artist =>
MediaItem?.Name, MediaItem?.Name,
ProgramScheduleItemCollectionType.MultiCollection =>
MultiCollection?.Name,
_ => string.Empty _ => string.Empty
}; };
} }

5
ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs

@ -1,11 +1,8 @@
using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.ProgramSchedules
namespace ErsatzTV.Application.ProgramSchedules
{ {
public record ProgramScheduleViewModel( public record ProgramScheduleViewModel(
int Id, int Id,
string Name, string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether, bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows); bool TreatCollectionsAsShows);
} }

1
ErsatzTV.Application/ProgramSchedules/Queries/GetAllProgramSchedulesHandler.cs

@ -24,7 +24,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
ps => new ProgramScheduleViewModel( ps => new ProgramScheduleViewModel(
ps.Id, ps.Id,
ps.Name, ps.Name,
ps.MediaCollectionPlaybackOrder,
ps.KeepMultiPartEpisodesTogether, ps.KeepMultiPartEpisodesTogether,
ps.TreatCollectionsAsShows)) ps.TreatCollectionsAsShows))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);

1
ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs

@ -27,6 +27,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
return await dbContext.ProgramScheduleItems return await dbContext.ProgramScheduleItems
.Filter(psi => psi.ProgramScheduleId == request.Id) .Filter(psi => psi.ProgramScheduleId == request.Id)
.Include(i => i.Collection) .Include(i => i.Collection)
.Include(i => i.MultiCollection)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata) .ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)

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

@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Core.Tests.Fakes namespace ErsatzTV.Core.Tests.Fakes
@ -18,7 +19,16 @@ namespace ErsatzTV.Core.Tests.Fakes
throw new NotSupportedException(); throw new NotSupportedException();
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask(); public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException(); public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId) =>
throw new NotSupportedException();
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask(); public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
} }
} }

90
ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs

@ -329,14 +329,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
Index = 1, Index = 1,
Collection = floodCollection, Collection = floodCollection,
CollectionId = floodCollection.Id, CollectionId = floodCollection.Id,
StartTime = null StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological
}, },
new ProgramScheduleItemOne new ProgramScheduleItemOne
{ {
Index = 2, Index = 2,
Collection = fixedCollection, Collection = fixedCollection,
CollectionId = fixedCollection.Id, CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(3) StartTime = TimeSpan.FromHours(3),
PlaybackOrder = PlaybackOrder.Chronological
} }
}; };
@ -344,8 +346,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
ProgramSchedule = new ProgramSchedule ProgramSchedule = new ProgramSchedule
{ {
Items = items, Items = items
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
}, },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
}; };
@ -409,7 +410,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Index = 1, Index = 1,
Collection = floodCollection, Collection = floodCollection,
CollectionId = floodCollection.Id, CollectionId = floodCollection.Id,
StartTime = null StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological
}, },
new ProgramScheduleItemMultiple new ProgramScheduleItemMultiple
{ {
@ -417,7 +419,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Collection = fixedCollection, Collection = fixedCollection,
CollectionId = fixedCollection.Id, CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(3), StartTime = TimeSpan.FromHours(3),
Count = 2 Count = 2,
PlaybackOrder = PlaybackOrder.Chronological
} }
}; };
@ -425,8 +428,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
ProgramSchedule = new ProgramSchedule ProgramSchedule = new ProgramSchedule
{ {
Items = items, Items = items
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
}, },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
}; };
@ -495,14 +497,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
Index = 1, Index = 1,
Collection = floodCollection, Collection = floodCollection,
CollectionId = floodCollection.Id, CollectionId = floodCollection.Id,
StartTime = TimeSpan.FromHours(7) StartTime = TimeSpan.FromHours(7),
PlaybackOrder = PlaybackOrder.Chronological
}, },
new ProgramScheduleItemOne new ProgramScheduleItemOne
{ {
Index = 2, Index = 2,
Collection = fixedCollection, Collection = fixedCollection,
CollectionId = fixedCollection.Id, CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(12) StartTime = TimeSpan.FromHours(12),
PlaybackOrder = PlaybackOrder.Chronological
} }
}; };
@ -510,8 +514,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
ProgramSchedule = new ProgramSchedule ProgramSchedule = new ProgramSchedule
{ {
Items = items, Items = items
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
}, },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
}; };
@ -579,14 +582,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
Index = 1, Index = 1,
Collection = floodCollection, Collection = floodCollection,
CollectionId = floodCollection.Id, CollectionId = floodCollection.Id,
StartTime = TimeSpan.FromHours(7) StartTime = TimeSpan.FromHours(7),
PlaybackOrder = PlaybackOrder.Chronological
}, },
new ProgramScheduleItemOne new ProgramScheduleItemOne
{ {
Index = 2, Index = 2,
Collection = fixedCollection, Collection = fixedCollection,
CollectionId = fixedCollection.Id, CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(12) StartTime = TimeSpan.FromHours(12),
PlaybackOrder = PlaybackOrder.Chronological
} }
}; };
@ -594,8 +599,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
ProgramSchedule = new ProgramSchedule ProgramSchedule = new ProgramSchedule
{ {
Items = items, Items = items
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
}, },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor Anchor = new PlayoutAnchor
@ -671,7 +675,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Index = 1, Index = 1,
Collection = floodCollection, Collection = floodCollection,
CollectionId = floodCollection.Id, CollectionId = floodCollection.Id,
StartTime = null StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological
}, },
new ProgramScheduleItemDuration new ProgramScheduleItemDuration
{ {
@ -680,7 +685,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = fixedCollection.Id, CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(2), StartTime = TimeSpan.FromHours(2),
PlayoutDuration = TimeSpan.FromHours(2), PlayoutDuration = TimeSpan.FromHours(2),
OfflineTail = false // immediately continue OfflineTail = false, // immediately continue
PlaybackOrder = PlaybackOrder.Chronological
} }
}; };
@ -688,8 +694,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
ProgramSchedule = new ProgramSchedule ProgramSchedule = new ProgramSchedule
{ {
Items = items, Items = items
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
}, },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
}; };
@ -762,7 +767,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Collection = multipleCollection, Collection = multipleCollection,
CollectionId = multipleCollection.Id, CollectionId = multipleCollection.Id,
StartTime = null, StartTime = null,
Count = 2 Count = 2,
PlaybackOrder = PlaybackOrder.Chronological
}, },
new ProgramScheduleItemDuration new ProgramScheduleItemDuration
{ {
@ -771,7 +777,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = dynamicCollection.Id, CollectionId = dynamicCollection.Id,
StartTime = null, StartTime = null,
PlayoutDuration = TimeSpan.FromHours(2), PlayoutDuration = TimeSpan.FromHours(2),
OfflineTail = false // immediately continue OfflineTail = false, // immediately continue
PlaybackOrder = PlaybackOrder.Chronological
} }
}; };
@ -779,8 +786,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
ProgramSchedule = new ProgramSchedule ProgramSchedule = new ProgramSchedule
{ {
Items = items, Items = items
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
}, },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
}; };
@ -850,7 +856,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Collection = collectionOne, Collection = collectionOne,
CollectionId = collectionOne.Id, CollectionId = collectionOne.Id,
StartTime = null, StartTime = null,
Count = 3 Count = 3,
PlaybackOrder = PlaybackOrder.Chronological
}, },
new ProgramScheduleItemMultiple new ProgramScheduleItemMultiple
{ {
@ -859,7 +866,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Collection = collectionTwo, Collection = collectionTwo,
CollectionId = collectionTwo.Id, CollectionId = collectionTwo.Id,
StartTime = null, StartTime = null,
Count = 3 Count = 3,
PlaybackOrder = PlaybackOrder.Chronological
} }
}; };
@ -867,8 +875,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
ProgramSchedule = new ProgramSchedule ProgramSchedule = new ProgramSchedule
{ {
Items = items, Items = items
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
}, },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor Anchor = new PlayoutAnchor
@ -945,7 +952,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Collection = collectionOne, Collection = collectionOne,
CollectionId = collectionOne.Id, CollectionId = collectionOne.Id,
StartTime = null, StartTime = null,
Count = 0 Count = 0,
PlaybackOrder = PlaybackOrder.Chronological
}, },
new ProgramScheduleItemMultiple new ProgramScheduleItemMultiple
{ {
@ -954,7 +962,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
Collection = collectionTwo, Collection = collectionTwo,
CollectionId = collectionTwo.Id, CollectionId = collectionTwo.Id,
StartTime = null, StartTime = null,
Count = 0 Count = 0,
PlaybackOrder = PlaybackOrder.Chronological
} }
}; };
@ -962,8 +971,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
ProgramSchedule = new ProgramSchedule ProgramSchedule = new ProgramSchedule
{ {
Items = items, Items = items
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
}, },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
}; };
@ -1033,7 +1041,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = collectionOne.Id, CollectionId = collectionOne.Id,
StartTime = null, StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3), PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false OfflineTail = false,
PlaybackOrder = PlaybackOrder.Chronological
}, },
new ProgramScheduleItemDuration new ProgramScheduleItemDuration
{ {
@ -1043,7 +1052,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = collectionTwo.Id, CollectionId = collectionTwo.Id,
StartTime = null, StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3), PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false OfflineTail = false,
PlaybackOrder = PlaybackOrder.Chronological
} }
}; };
@ -1051,8 +1061,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
ProgramSchedule = new ProgramSchedule ProgramSchedule = new ProgramSchedule
{ {
Items = items, Items = items
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
}, },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor Anchor = new PlayoutAnchor
@ -1095,13 +1104,14 @@ namespace ErsatzTV.Core.Tests.Scheduling
return now - now.TimeOfDay + TimeSpan.FromHours(hours); return now - now.TimeOfDay + TimeSpan.FromHours(hours);
} }
private static ProgramScheduleItem Flood(Collection mediaCollection) => private static ProgramScheduleItem Flood(Collection mediaCollection, PlaybackOrder playbackOrder) =>
new ProgramScheduleItemFlood new ProgramScheduleItemFlood
{ {
Index = 1, Index = 1,
Collection = mediaCollection, Collection = mediaCollection,
CollectionId = mediaCollection.Id, CollectionId = mediaCollection.Id,
StartTime = null StartTime = null,
PlaybackOrder = playbackOrder
}; };
private static Movie TestMovie(int id, TimeSpan duration, DateTime aired) => private static Movie TestMovie(int id, TimeSpan duration, DateTime aired) =>
@ -1128,12 +1138,12 @@ namespace ErsatzTV.Core.Tests.Scheduling
var artistRepo = new Mock<IArtistRepository>(); var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(collectionRepo, televisionRepo, artistRepo.Object, _logger); var builder = new PlayoutBuilder(collectionRepo, televisionRepo, artistRepo.Object, _logger);
var items = new List<ProgramScheduleItem> { Flood(mediaCollection) }; var items = new List<ProgramScheduleItem> { Flood(mediaCollection, playbackOrder) };
var playout = new Playout var playout = new Playout
{ {
Id = 1, Id = 1,
ProgramSchedule = new ProgramSchedule { Items = items, MediaCollectionPlaybackOrder = playbackOrder }, ProgramSchedule = new ProgramSchedule { Items = items },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
}; };

18
ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs

@ -23,7 +23,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
// normally returns 10 5 7 4 3 6 2 8 9 1 1 (note duplicate 1 at end) // normally returns 10 5 7 4 3 6 2 8 9 1 1 (note duplicate 1 at end)
var state = new CollectionEnumeratorState { Seed = 8 }; var state = new CollectionEnumeratorState { Seed = 8 };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false); var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
var list = new List<int>(); var list = new List<int>();
for (var i = 1; i <= 1000; i++) for (var i = 1; i <= 1000; i++)
@ -50,7 +51,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState(); var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false); var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
var list = new List<int>(); var list = new List<int>();
for (var i = 1; i <= 10; i++) for (var i = 1; i <= 10; i++)
@ -70,7 +72,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState(); var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false); var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
var list = new List<int>(); var list = new List<int>();
for (var i = 1; i <= 10; i++) for (var i = 1; i <= 10; i++)
@ -90,7 +93,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10); List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState(); var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false); var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
for (var i = 0; i < 10; i++) for (var i = 0; i < 10; i++)
{ {
@ -105,7 +109,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10); List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 5, Seed = MagicSeed }; var state = new CollectionEnumeratorState { Index = 5, Seed = MagicSeed };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false); var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
for (var i = 6; i <= 10; i++) for (var i = 6; i <= 10; i++)
{ {
@ -123,7 +128,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10); List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 10, Seed = MagicSeed }; var state = new CollectionEnumeratorState { Index = 10, Seed = MagicSeed };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false); var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
shuffledContent.State.Index.Should().Be(0); shuffledContent.State.Index.Should().Be(0);
shuffledContent.State.Seed.Should().NotBe(MagicSeed); shuffledContent.State.Seed.Should().NotBe(MagicSeed);

2
ErsatzTV.Core/Domain/Collection/Collection.cs

@ -9,5 +9,7 @@ namespace ErsatzTV.Core.Domain
public bool UseCustomPlaybackOrder { get; set; } public bool UseCustomPlaybackOrder { get; set; }
public List<MediaItem> MediaItems { get; set; } public List<MediaItem> MediaItems { get; set; }
public List<CollectionItem> CollectionItems { get; set; } public List<CollectionItem> CollectionItems { get; set; }
public List<MultiCollection> MultiCollections { get; set; }
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
} }
} }

12
ErsatzTV.Core/Domain/Collection/MultiCollection.cs

@ -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; }
}
}

12
ErsatzTV.Core/Domain/Collection/MultiCollectionItem.cs

@ -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; }
}
}

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -17,6 +17,7 @@
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count"); public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size"); public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size"); public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
public static ConfigElementKey MultiCollectionsPageSize => new("pages.multi_collections.page_size");
public static ConfigElementKey SchedulesPageSize => new("pages.schedules.page_size"); public static ConfigElementKey SchedulesPageSize => new("pages.schedules.page_size");
public static ConfigElementKey SchedulesDetailPageSize => new("pages.schedules.detail_page_size"); public static ConfigElementKey SchedulesDetailPageSize => new("pages.schedules.detail_page_size");
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size"); public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");

2
ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs

@ -10,6 +10,8 @@
public ProgramScheduleItemCollectionType CollectionType { get; set; } public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? CollectionId { get; set; } public int? CollectionId { get; set; }
public Collection Collection { get; set; } public Collection Collection { get; set; }
public int? MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int? MediaItemId { get; set; } public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; } public MediaItem MediaItem { get; set; }
public CollectionEnumeratorState EnumeratorState { get; set; } public CollectionEnumeratorState EnumeratorState { get; set; }

1
ErsatzTV.Core/Domain/ProgramSchedule.cs

@ -6,7 +6,6 @@ namespace ErsatzTV.Core.Domain
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public PlaybackOrder MediaCollectionPlaybackOrder { get; set; }
public bool KeepMultiPartEpisodesTogether { get; set; } public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; } public bool TreatCollectionsAsShows { get; set; }
public List<ProgramScheduleItem> Items { get; set; } public List<ProgramScheduleItem> Items { get; set; }

3
ErsatzTV.Core/Domain/ProgramScheduleItem.cs

@ -16,5 +16,8 @@ namespace ErsatzTV.Core.Domain
public Collection Collection { get; set; } public Collection Collection { get; set; }
public int? MediaItemId { get; set; } public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; } public MediaItem MediaItem { get; set; }
public int? MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
} }
} }

3
ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs

@ -5,6 +5,7 @@
Collection = 0, Collection = 0,
TelevisionShow = 1, TelevisionShow = 1,
TelevisionSeason = 2, TelevisionSeason = 2,
Artist = 3 Artist = 3,
MultiCollection = 4
} }
} }

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

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories namespace ErsatzTV.Core.Interfaces.Repositories
@ -9,7 +10,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{ {
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id); Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id);
Task<List<MediaItem>> GetItems(int id); Task<List<MediaItem>> GetItems(int id);
Task<List<MediaItem>> GetMultiCollectionItems(int id);
Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id);
Task<List<int>> PlayoutIdsUsingCollection(int collectionId); Task<List<int>> PlayoutIdsUsingCollection(int collectionId);
Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId);
Task<bool> IsCustomPlaybackOrder(int collectionId); Task<bool> IsCustomPlaybackOrder(int collectionId);
} }
} }

12
ErsatzTV.Core/Scheduling/CollectionWithItems.cs

@ -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);
}

35
ErsatzTV.Core/Scheduling/GroupedMediaItem.cs

@ -1,9 +1,40 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Scheduling namespace ErsatzTV.Core.Scheduling
{ {
[DebuggerDisplay("{First}")] [DebuggerDisplay("{" + nameof(First) + "}")]
public record GroupedMediaItem(MediaItem First, List<MediaItem> Additional); 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;
}
}
} }

41
ErsatzTV.Core/Scheduling/MultiCollectionGroup.cs

@ -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}");
}
}
}
}
}

26
ErsatzTV.Core/Scheduling/MultiCollectionGrouper.cs

@ -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;
}
}
}

18
ErsatzTV.Core/Scheduling/MultiPartEpisodeGrouper.cs

@ -77,7 +77,7 @@ namespace ErsatzTV.Core.Scheduling
// add to current group // add to current group
List<MediaItem> additional = group.Additional ?? new List<MediaItem>(); List<MediaItem> additional = group.Additional ?? new List<MediaItem>();
additional.Add(episode); additional.Add(episode);
group = group with { Additional = additional }; group = new GroupedMediaItem(group.First, additional);
} }
else else
{ {
@ -152,22 +152,6 @@ namespace ErsatzTV.Core.Scheduling
return None; return None;
} }
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;
}
private static bool TryParseRoman(string input, out int output) private static bool TryParseRoman(string input, out int output)
{ {
switch (input?.ToLowerInvariant()) switch (input?.ToLowerInvariant())

49
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -82,6 +82,11 @@ namespace ErsatzTV.Core.Scheduling
List<MusicVideo> artistItems = List<MusicVideo> artistItems =
await _artistRepository.GetArtistItems(collectionKey.MediaItemId ?? 0); await _artistRepository.GetArtistItems(collectionKey.MediaItemId ?? 0);
return Tuple(collectionKey, artistItems.Cast<MediaItem>().ToList()); return Tuple(collectionKey, artistItems.Cast<MediaItem>().ToList());
case ProgramScheduleItemCollectionType.MultiCollection:
List<MediaItem> multiCollectionItems =
await _mediaCollectionRepository.GetMultiCollectionItems(
collectionKey.MultiCollectionId ?? 0);
return Tuple(collectionKey, multiCollectionItems);
default: default:
return Tuple(collectionKey, new List<MediaItem>()); return Tuple(collectionKey, new List<MediaItem>());
} }
@ -178,8 +183,11 @@ namespace ErsatzTV.Core.Scheduling
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>(); var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems) foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems)
{ {
PlaybackOrder playbackOrder = sortedScheduleItems
.First(item => CollectionKeyForItem(item) == collectionKey)
.PlaybackOrder;
IMediaCollectionEnumerator enumerator = IMediaCollectionEnumerator enumerator =
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems); await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems, playbackOrder);
collectionEnumerators.Add(collectionKey, enumerator); collectionEnumerators.Add(collectionKey, enumerator);
} }
@ -490,6 +498,7 @@ namespace ErsatzTV.Core.Scheduling
ProgramScheduleId = playout.ProgramScheduleId, ProgramScheduleId = playout.ProgramScheduleId,
CollectionType = collectionKey.CollectionType, CollectionType = collectionKey.CollectionType,
CollectionId = collectionKey.CollectionId, CollectionId = collectionKey.CollectionId,
MultiCollectionId = collectionKey.MultiCollectionId,
MediaItemId = collectionKey.MediaItemId, MediaItemId = collectionKey.MediaItemId,
EnumeratorState = maybeEnumeratorState[collectionKey] EnumeratorState = maybeEnumeratorState[collectionKey]
}); });
@ -503,12 +512,14 @@ namespace ErsatzTV.Core.Scheduling
private async Task<IMediaCollectionEnumerator> GetMediaCollectionEnumerator( private async Task<IMediaCollectionEnumerator> GetMediaCollectionEnumerator(
Playout playout, Playout playout,
CollectionKey collectionKey, CollectionKey collectionKey,
List<MediaItem> mediaItems) List<MediaItem> mediaItems,
PlaybackOrder playbackOrder)
{ {
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors.FirstOrDefault( Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors.FirstOrDefault(
a => a.ProgramScheduleId == playout.ProgramScheduleId a => a.ProgramScheduleId == playout.ProgramScheduleId
&& a.CollectionType == collectionKey.CollectionType && a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId && a.CollectionId == collectionKey.CollectionId
&& a.MultiCollectionId == collectionKey.MultiCollectionId
&& a.MediaItemId == collectionKey.MediaItemId); && a.MediaItemId == collectionKey.MediaItemId);
CollectionEnumeratorState state = maybeAnchor.Match( CollectionEnumeratorState state = maybeAnchor.Match(
@ -531,7 +542,7 @@ namespace ErsatzTV.Core.Scheduling
} }
} }
switch (playout.ProgramSchedule.MediaCollectionPlaybackOrder) switch (playbackOrder)
{ {
case PlaybackOrder.Chronological: case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(mediaItems, state); return new ChronologicalMediaCollectionEnumerator(mediaItems, state);
@ -539,16 +550,34 @@ namespace ErsatzTV.Core.Scheduling
return new RandomizedMediaCollectionEnumerator(mediaItems, state); return new RandomizedMediaCollectionEnumerator(mediaItems, state);
case PlaybackOrder.Shuffle: case PlaybackOrder.Shuffle:
return new ShuffledMediaCollectionEnumerator( return new ShuffledMediaCollectionEnumerator(
mediaItems, await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey),
state, state);
playout.ProgramSchedule.KeepMultiPartEpisodesTogether,
playout.ProgramSchedule.TreatCollectionsAsShows);
default: default:
// TODO: handle this error case differently? // TODO: handle this error case differently?
return new RandomizedMediaCollectionEnumerator(mediaItems, state); return new RandomizedMediaCollectionEnumerator(mediaItems, state);
} }
} }
private async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
Playout playout,
List<MediaItem> mediaItems,
CollectionKey collectionKey)
{
if (collectionKey.MultiCollectionId != null)
{
List<CollectionWithItems> collections = await _mediaCollectionRepository
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value);
return MultiCollectionGrouper.GroupMediaItems(collections);
}
return playout.ProgramSchedule.KeepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(
mediaItems,
playout.ProgramSchedule.TreatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
}
private static string DisplayTitle(MediaItem mediaItem) private static string DisplayTitle(MediaItem mediaItem)
{ {
switch (mediaItem) switch (mediaItem)
@ -595,6 +624,11 @@ namespace ErsatzTV.Core.Scheduling
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
MediaItemId = item.MediaItemId MediaItemId = item.MediaItemId
}, },
ProgramScheduleItemCollectionType.MultiCollection => new CollectionKey
{
CollectionType = item.CollectionType,
MultiCollectionId = item.MultiCollectionId
},
_ => throw new ArgumentOutOfRangeException(nameof(item)) _ => throw new ArgumentOutOfRangeException(nameof(item))
}; };
@ -602,6 +636,7 @@ namespace ErsatzTV.Core.Scheduling
{ {
public ProgramScheduleItemCollectionType CollectionType { get; set; } public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? CollectionId { get; set; } public int? CollectionId { get; set; }
public int? MultiCollectionId { get; set; }
public int? MediaItemId { get; set; } public int? MediaItemId { get; set; }
} }
} }

15
ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

@ -16,16 +16,11 @@ namespace ErsatzTV.Core.Scheduling
private IList<MediaItem> _shuffled; private IList<MediaItem> _shuffled;
public ShuffledMediaCollectionEnumerator( public ShuffledMediaCollectionEnumerator(
IList<MediaItem> mediaItems, IList<GroupedMediaItem> mediaItems,
CollectionEnumeratorState state, CollectionEnumeratorState state)
bool keepMultiPartEpisodesTogether,
bool treatCollectionsAsShows)
{ {
_mediaItemCount = mediaItems.Count; _mediaItemCount = mediaItems.Sum(i => 1 + Optional(i.Additional).Flatten().Count());
_mediaItems = mediaItems;
_mediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, treatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
if (state.Index >= _mediaItems.Count) if (state.Index >= _mediaItems.Count)
{ {
@ -83,7 +78,7 @@ namespace ErsatzTV.Core.Scheduling
copy[n] = value; copy[n] = value;
} }
return MultiPartEpisodeGrouper.FlattenGroups(copy, _mediaItemCount); return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount);
} }
} }
} }

27
ErsatzTV.Infrastructure/Data/Configurations/Collection/MultiCollectionConfiguration.cs

@ -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 }));
}
}
}

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

@ -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");
}
}

6
ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs

@ -19,6 +19,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(false); .IsRequired(false);
builder.HasOne(i => i.MultiCollection)
.WithMany()
.HasForeignKey(i => i.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.MediaItem) builder.HasOne(i => i.MediaItem)
.WithMany() .WithMany()
.HasForeignKey(i => i.MediaItemId) .HasForeignKey(i => i.MediaItemId)

6
ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs

@ -21,6 +21,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.HasForeignKey(i => i.MediaItemId) .HasForeignKey(i => i.MediaItemId)
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(false); .IsRequired(false);
builder.HasOne(i => i.MultiCollection)
.WithMany()
.HasForeignKey(i => i.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
} }
} }
} }

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

@ -5,6 +5,8 @@ using System.Threading.Tasks;
using Dapper; using Dapper;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@ -50,16 +52,113 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return result.Distinct().ToList(); return result.Distinct().ToList();
} }
public async Task<List<MediaItem>> GetMultiCollectionItems(int id)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
var result = new List<MediaItem>();
Option<MultiCollection> maybeMultiCollection = await dbContext.MultiCollections
.Include(mc => mc.Collections)
.SelectOneAsync(mc => mc.Id, mc => mc.Id == id);
foreach (MultiCollection multiCollection in maybeMultiCollection)
{
foreach (int collectionId in multiCollection.Collections.Map(c => c.Id))
{
result.AddRange(await GetMovieItems(dbContext, collectionId));
result.AddRange(await GetShowItems(dbContext, collectionId));
result.AddRange(await GetSeasonItems(dbContext, collectionId));
result.AddRange(await GetEpisodeItems(dbContext, collectionId));
result.AddRange(await GetArtistItems(dbContext, collectionId));
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
}
}
return result.Distinct().ToList();
}
public async Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
var result = new List<CollectionWithItems>();
Option<MultiCollection> maybeMultiCollection = await dbContext.MultiCollections
.Include(mc => mc.Collections)
.Include(mc => mc.MultiCollectionItems)
.ThenInclude(mci => mci.Collection)
.SelectOneAsync(mc => mc.Id, mc => mc.Id == id);
foreach (MultiCollection multiCollection in maybeMultiCollection)
{
foreach (MultiCollectionItem multiCollectionItem in multiCollection.MultiCollectionItems)
{
List<MediaItem> items = await GetItems(multiCollectionItem.CollectionId);
if (multiCollectionItem.Collection.UseCustomPlaybackOrder)
{
foreach (Collection collection in await GetCollectionWithCollectionItemsUntracked(
multiCollectionItem.CollectionId))
{
var sortedItems = collection.CollectionItems
.OrderBy(ci => ci.CustomIndex)
.Map(ci => items.First(i => i.Id == ci.MediaItemId))
.ToList();
result.Add(
new CollectionWithItems(
multiCollectionItem.CollectionId,
sortedItems,
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder,
multiCollectionItem.Collection.UseCustomPlaybackOrder));
}
}
else
{
result.Add(
new CollectionWithItems(
multiCollectionItem.CollectionId,
items,
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder,
multiCollectionItem.Collection.UseCustomPlaybackOrder));
}
}
}
// remove duplicate items from ungrouped collections
var toRemoveFrom = result.Filter(c => !c.ScheduleAsGroup).ToList();
var toRemove = result.Filter(c => c.ScheduleAsGroup)
.SelectMany(c => c.MediaItems.Map(i => i.Id))
.Distinct()
.ToList();
foreach (CollectionWithItems collection in toRemoveFrom)
{
collection.MediaItems.RemoveAll(mi => toRemove.Contains(mi.Id));
}
return result;
}
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) =>
_dbConnection.QueryAsync<int>( _dbConnection.QueryAsync<int>(
@"SELECT DISTINCT p.Id @"SELECT DISTINCT p.PlayoutId
FROM Playout p FROM PlayoutProgramScheduleAnchor p
INNER JOIN ProgramSchedule PS on p.ProgramScheduleId = PS.Id WHERE p.CollectionId = @CollectionId",
INNER JOIN ProgramScheduleItem PSI on p.Anchor_NextScheduleItemId = PSI.Id
WHERE PSI.CollectionId = @CollectionId",
new { CollectionId = collectionId }) new { CollectionId = collectionId })
.Map(result => result.ToList()); .Map(result => result.ToList());
public Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId) =>
_dbConnection.QueryAsync<int>(
@"SELECT DISTINCT p.PlayoutId
FROM PlayoutProgramScheduleAnchor p
WHERE p.MultiCollectionId = @MultiCollectionId",
new { MultiCollectionId = multiCollectionId })
.Map(result => result.ToList());
public Task<bool> IsCustomPlaybackOrder(int collectionId) => public Task<bool> IsCustomPlaybackOrder(int collectionId) =>
_dbConnection.QuerySingleAsync<bool>( _dbConnection.QuerySingleAsync<bool>(
@"SELECT IFNULL(MIN(UseCustomPlaybackOrder), 0) FROM Collection WHERE Id = @CollectionId", @"SELECT IFNULL(MIN(UseCustomPlaybackOrder), 0) FROM Collection WHERE Id = @CollectionId",

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -58,6 +58,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<EmbyEpisode> EmbyEpisodes { get; set; } public DbSet<EmbyEpisode> EmbyEpisodes { get; set; }
public DbSet<Collection> Collections { get; set; } public DbSet<Collection> Collections { get; set; }
public DbSet<CollectionItem> CollectionItems { get; set; } public DbSet<CollectionItem> CollectionItems { get; set; }
public DbSet<MultiCollection> MultiCollections { get; set; }
public DbSet<ProgramSchedule> ProgramSchedules { get; set; } public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
public DbSet<ProgramScheduleItem> ProgramScheduleItems { get; set; } public DbSet<ProgramScheduleItem> ProgramScheduleItems { get; set; }
public DbSet<Playout> Playouts { get; set; } public DbSet<Playout> Playouts { get; set; }

3064
ErsatzTV.Infrastructure/Migrations/20210717172758_Add_MultiCollection.Designer.cs generated

File diff suppressed because it is too large Load Diff

125
ErsatzTV.Infrastructure/Migrations/20210717172758_Add_MultiCollection.cs

@ -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");
}
}
}

3067
ErsatzTV.Infrastructure/Migrations/20210717190207_Add_ProgramScheduleItemPlaybackOrder.Designer.cs generated

File diff suppressed because it is too large Load Diff

27
ErsatzTV.Infrastructure/Migrations/20210717190207_Add_ProgramScheduleItemPlaybackOrder.cs

@ -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");
}
}
}

3064
ErsatzTV.Infrastructure/Migrations/20210717195354_Remove_ProgramScheduleMediaCollectionPlaybackOrder.Designer.cs generated

File diff suppressed because it is too large Load Diff

24
ErsatzTV.Infrastructure/Migrations/20210717195354_Remove_ProgramScheduleMediaCollectionPlaybackOrder.cs

@ -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);
}
}
}

93
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "5.0.7"); .HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{ {
@ -944,6 +944,41 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MovieMetadata"); b.ToTable("MovieMetadata");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("MultiCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollectionItem", b =>
{
b.Property<int>("MultiCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("CollectionId")
.HasColumnType("INTEGER");
b.Property<int>("PlaybackOrder")
.HasColumnType("INTEGER");
b.Property<bool>("ScheduleAsGroup")
.HasColumnType("INTEGER");
b.HasKey("MultiCollectionId", "CollectionId");
b.HasIndex("CollectionId");
b.ToTable("MultiCollectionItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b => modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -1062,6 +1097,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MediaItemId") b.Property<int?>("MediaItemId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("MultiCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutId") b.Property<int>("PlayoutId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -1074,6 +1112,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MediaItemId"); b.HasIndex("MediaItemId");
b.HasIndex("MultiCollectionId");
b.HasIndex("PlayoutId"); b.HasIndex("PlayoutId");
b.HasIndex("ProgramScheduleId"); b.HasIndex("ProgramScheduleId");
@ -1134,9 +1174,6 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<bool>("KeepMultiPartEpisodesTogether") b.Property<bool>("KeepMultiPartEpisodesTogether")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("MediaCollectionPlaybackOrder")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -1172,6 +1209,12 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MediaItemId") b.Property<int?>("MediaItemId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("MultiCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("PlaybackOrder")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId") b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -1184,6 +1227,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MediaItemId"); b.HasIndex("MediaItemId");
b.HasIndex("MultiCollectionId");
b.HasIndex("ProgramScheduleId"); b.HasIndex("ProgramScheduleId");
b.ToTable("ProgramScheduleItem"); b.ToTable("ProgramScheduleItem");
@ -2156,6 +2201,25 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Movie"); b.Navigation("Movie");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollectionItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
.WithMany("MultiCollectionItems")
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "MultiCollection")
.WithMany("MultiCollectionItems")
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Collection");
b.Navigation("MultiCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b => modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
{ {
b.HasOne("ErsatzTV.Core.Domain.MusicVideo", "MusicVideo") b.HasOne("ErsatzTV.Core.Domain.MusicVideo", "MusicVideo")
@ -2257,6 +2321,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MediaItemId") .HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "MultiCollection")
.WithMany()
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("ProgramScheduleAnchors") .WithMany("ProgramScheduleAnchors")
.HasForeignKey("PlayoutId") .HasForeignKey("PlayoutId")
@ -2294,6 +2363,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MediaItem"); b.Navigation("MediaItem");
b.Navigation("MultiCollection");
b.Navigation("Playout"); b.Navigation("Playout");
b.Navigation("ProgramSchedule"); b.Navigation("ProgramSchedule");
@ -2333,6 +2404,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MediaItemId") .HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "MultiCollection")
.WithMany()
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Items") .WithMany("Items")
.HasForeignKey("ProgramScheduleId") .HasForeignKey("ProgramScheduleId")
@ -2343,6 +2419,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MediaItem"); b.Navigation("MediaItem");
b.Navigation("MultiCollection");
b.Navigation("ProgramSchedule"); b.Navigation("ProgramSchedule");
}); });
@ -2781,6 +2859,8 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{ {
b.Navigation("CollectionItems"); b.Navigation("CollectionItems");
b.Navigation("MultiCollectionItems");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b =>
@ -2850,6 +2930,11 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Writers"); b.Navigation("Writers");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollection", b =>
{
b.Navigation("MultiCollectionItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b => modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
{ {
b.Navigation("Actors"); b.Navigation("Actors");

58
ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs

@ -51,37 +51,35 @@ namespace ErsatzTV.Infrastructure.Plex
.Append(httpsResources.Filter(resource => resource.HttpsRequired)) .Append(httpsResources.Filter(resource => resource.HttpsRequired))
.ToList(); .ToList();
IEnumerable<PlexMediaSource> sources = await allResources IEnumerable<PlexResource> ownedResources = allResources
.Filter(r => r.Provides.Split(",").Any(p => p == "server")) .Filter(r => r.Provides.Split(",").Any(p => p == "server"))
.Filter(r => r.Owned) // TODO: maybe support non-owned servers in the future .Filter(r => r.Owned); // TODO: maybe support non-owned servers in the future
.Map(
async resource =>
{ foreach (PlexResource resource in ownedResources)
var serverAuthToken = new PlexServerAuthToken( {
resource.ClientIdentifier, var serverAuthToken = new PlexServerAuthToken(
resource.AccessToken); resource.ClientIdentifier,
resource.AccessToken);
await _plexSecretStore.UpsertServerAuthToken(serverAuthToken);
List<PlexResourceConnection> sortedConnections = resource.HttpsRequired await _plexSecretStore.UpsertServerAuthToken(serverAuthToken);
? resource.Connections List<PlexResourceConnection> sortedConnections = resource.HttpsRequired
: resource.Connections.OrderBy(c => c.Local ? 0 : 1).ToList(); ? resource.Connections
: resource.Connections.OrderBy(c => c.Local ? 0 : 1).ToList();
var source = new PlexMediaSource
{ var source = new PlexMediaSource
ServerName = resource.Name, {
ProductVersion = resource.ProductVersion, ServerName = resource.Name,
Platform = resource.Platform, ProductVersion = resource.ProductVersion,
PlatformVersion = resource.PlatformVersion, Platform = resource.Platform,
ClientIdentifier = resource.ClientIdentifier, PlatformVersion = resource.PlatformVersion,
Connections = sortedConnections ClientIdentifier = resource.ClientIdentifier,
.Map(c => new PlexConnection { Uri = c.Uri }).ToList() Connections = sortedConnections
}; .Map(c => new PlexConnection { Uri = c.Uri }).ToList()
};
return source;
}) result.Add(source);
.Sequence(); }
result.AddRange(sources);
} }
return result; return result;

2
ErsatzTV/Pages/Artist.razor

@ -206,7 +206,7 @@
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule) if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{ {
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, ArtistId, null, null, null, null)); await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, ArtistId, PlaybackOrder.Shuffle, null, null, null, null));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items"); _navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
} }
} }

2
ErsatzTV/Pages/CollectionEditor.razor

@ -33,7 +33,7 @@
[Parameter] [Parameter]
public int Id { get; set; } public int Id { get; set; }
private readonly SimpleMediaCollectionEditViewModel _model = new(); private readonly CollectionEditViewModel _model = new();
private EditContext _editContext; private EditContext _editContext;
private ValidationMessageStore _messageStore; private ValidationMessageStore _messageStore;

118
ErsatzTV/Pages/Collections.razor

@ -9,11 +9,20 @@
@inject IMediator _mediator @inject IMediator _mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" <div>
@bind-RowsPerPage="@_rowsPerPage" <MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/media/collections/add">
ServerData="@(new Func<TableState, Task<TableData<MediaCollectionViewModel>>>(ServerReload))" Add Collection
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" Link="/media/multi-collections/add">
Add Multi Collection
</MudButton>
</div>
<MudTable Class="mt-4"
Hover="true"
@bind-RowsPerPage="@_collectionsRowsPerPage"
ServerData="@(new Func<TableState, Task<TableData<MediaCollectionViewModel>>>(ServerReloadCollections))"
Dense="true" Dense="true"
@ref="_table"> @ref="_collectionsTable">
<ToolBarContent> <ToolBarContent>
<MudText Typo="Typo.h6">Collections</MudText> <MudText Typo="Typo.h6">Collections</MudText>
</ToolBarContent> </ToolBarContent>
@ -46,38 +55,91 @@
<MudTablePager/> <MudTablePager/>
</PagerContent> </PagerContent>
</MudTable> </MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/media/collections/add" Class="mt-4"> <MudTable Class="mt-4"
Add Collection Hover="true"
</MudButton> @bind-RowsPerPage="@_multiCollectionsRowsPerPage"
ServerData="@(new Func<TableState, Task<TableData<MultiCollectionViewModel>>>(ServerReloadMultiCollections))"
Dense="true"
@ref="_multiCollectionsTable">
<ToolBarContent>
<MudText Typo="Typo.h6">Multi Collections</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Collection">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"/media/multi-collections/{context.Id}/edit")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Collection">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteMultiCollection(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
</MudContainer> </MudContainer>
@code { @code {
private MudTable<MediaCollectionViewModel> _table; private MudTable<MediaCollectionViewModel> _collectionsTable;
private MudTable<MultiCollectionViewModel> _multiCollectionsTable;
private int _rowsPerPage; private int _collectionsRowsPerPage;
private int _multiCollectionsRowsPerPage;
protected override async Task OnParametersSetAsync() => _rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.CollectionsPageSize)) protected override async Task OnParametersSetAsync()
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); {
_collectionsRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.CollectionsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
_multiCollectionsRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.MultiCollectionsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
private async Task DeleteMediaCollection(MediaCardViewModel vm) private async Task DeleteMediaCollection(MediaCollectionViewModel collection)
{ {
if (vm is MediaCollectionViewModel collection) var parameters = new DialogParameters { { "EntityType", "collection" }, { "EntityName", collection.Name } };
{ var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
var parameters = new DialogParameters { { "EntityType", "collection" }, { "EntityName", collection.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Collection", parameters, options); IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Collection", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
await _mediator.Send(new DeleteCollection(collection.Id)); await _mediator.Send(new DeleteCollection(collection.Id));
await _table.ReloadServerData(); await _collectionsTable.ReloadServerData();
}
} }
} }
private async Task DeleteMultiCollection(MultiCollectionViewModel collection)
{
var parameters = new DialogParameters { { "EntityType", "multi collection" }, { "EntityName", collection.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
private async Task<TableData<MediaCollectionViewModel>> ServerReload(TableState state) IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Multi Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
await _mediator.Send(new DeleteMultiCollection(collection.Id));
await _multiCollectionsTable.ReloadServerData();
}
}
private async Task<TableData<MediaCollectionViewModel>> ServerReloadCollections(TableState state)
{ {
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.CollectionsPageSize, state.PageSize.ToString())); await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.CollectionsPageSize, state.PageSize.ToString()));
@ -85,4 +147,12 @@
return new TableData<MediaCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page }; return new TableData<MediaCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
} }
private async Task<TableData<MultiCollectionViewModel>> ServerReloadMultiCollections(TableState state)
{
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.MultiCollectionsPageSize, state.PageSize.ToString()));
PagedMultiCollectionsViewModel data = await _mediator.Send(new GetPagedMultiCollections(state.Page, state.PageSize));
return new TableData<MultiCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
} }

171
ErsatzTV/Pages/MultiCollectionEditor.razor

@ -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();
}

9
ErsatzTV/Pages/ScheduleEditor.razor

@ -16,16 +16,9 @@
<MudCard> <MudCard>
<MudCardContent> <MudCardContent>
<MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/> <MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudSelect Class="mt-3" Label="Collection Playback Order" @bind-Value="_model.MediaCollectionPlaybackOrder" For="@(() => _model.MediaCollectionPlaybackOrder)">
@foreach (PlaybackOrder playbackOrder in Enum.GetValues<PlaybackOrder>())
{
<MudSelectItem Value="@playbackOrder">@playbackOrder</MudSelectItem>
}
</MudSelect>
<MudElement HtmlTag="div" Class="mt-3"> <MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Label="Keep Multi-Part Episodes Together" <MudCheckBox Label="Keep Multi-Part Episodes Together"
@bind-Checked="@_model.KeepMultiPartEpisodesTogether" @bind-Checked="@_model.KeepMultiPartEpisodesTogether"
Disabled="@(_model.MediaCollectionPlaybackOrder != PlaybackOrder.Shuffle)"
For="@(() => _model.KeepMultiPartEpisodesTogether)"/> For="@(() => _model.KeepMultiPartEpisodesTogether)"/>
</MudElement> </MudElement>
<MudElement HtmlTag="div" Class="mt-3"> <MudElement HtmlTag="div" Class="mt-3">
@ -66,7 +59,6 @@
{ {
_model.Id = viewModel.Id; _model.Id = viewModel.Id;
_model.Name = viewModel.Name; _model.Name = viewModel.Name;
_model.MediaCollectionPlaybackOrder = viewModel.MediaCollectionPlaybackOrder;
_model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether; _model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether;
_model.TreatCollectionsAsShows = viewModel.TreatCollectionsAsShows; _model.TreatCollectionsAsShows = viewModel.TreatCollectionsAsShows;
}, },
@ -75,7 +67,6 @@
else else
{ {
_model.Name = "New Schedule"; _model.Name = "New Schedule";
_model.MediaCollectionPlaybackOrder = PlaybackOrder.Shuffle;
} }
} }

26
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -109,6 +109,15 @@
SearchFunc="@SearchMediaCollections" SearchFunc="@SearchMediaCollections"
ToStringFunc="@(c => c?.Name)"/> ToStringFunc="@(c => c?.Name)"/>
} }
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.MultiCollection)
{
<MudAutocomplete Class="mt-3"
T="MultiCollectionViewModel"
Label="Multi Collection"
@bind-value="_selectedItem.MultiCollection"
SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)"/>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow) @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow)
{ {
<MudAutocomplete Class="mt-3" <MudAutocomplete Class="mt-3"
@ -136,6 +145,12 @@
SearchFunc="@SearchArtists" SearchFunc="@SearchArtists"
ToStringFunc="@(s => s?.Name)"/> ToStringFunc="@(s => s?.Name)"/>
} }
<MudSelect Class="mt-3" Label="Playback Order" @bind-Value="@_selectedItem.PlaybackOrder" For="@(() => _selectedItem.PlaybackOrder)" Disabled="@(_selectedItem.CollectionType == ProgramScheduleItemCollectionType.MultiCollection)">
@foreach (PlaybackOrder playbackOrder in Enum.GetValues<PlaybackOrder>())
{
<MudSelectItem Value="@playbackOrder">@playbackOrder</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)"> <MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
@foreach (PlayoutMode playoutMode in Enum.GetValues<PlayoutMode>()) @foreach (PlayoutMode playoutMode in Enum.GetValues<PlayoutMode>())
{ {
@ -182,6 +197,7 @@
private ProgramScheduleItemsEditViewModel _schedule; private ProgramScheduleItemsEditViewModel _schedule;
private List<MediaCollectionViewModel> _mediaCollections; private List<MediaCollectionViewModel> _mediaCollections;
private List<MultiCollectionViewModel> _multiCollections;
private List<NamedMediaItemViewModel> _televisionShows; private List<NamedMediaItemViewModel> _televisionShows;
private List<NamedMediaItemViewModel> _televisionSeasons; private List<NamedMediaItemViewModel> _televisionSeasons;
private List<NamedMediaItemViewModel> _artists; private List<NamedMediaItemViewModel> _artists;
@ -195,6 +211,8 @@
// TODO: fix performance // TODO: fix performance
_mediaCollections = await _mediator.Send(new GetAllCollections()) _mediaCollections = await _mediator.Send(new GetAllCollections())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); .Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_multiCollections = await _mediator.Send(new GetAllMultiCollections())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_televisionShows = await _mediator.Send(new GetAllTelevisionShows()) _televisionShows = await _mediator.Send(new GetAllTelevisionShows())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); .Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons()) _televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons())
@ -225,7 +243,9 @@
PlayoutMode = item.PlayoutMode, PlayoutMode = item.PlayoutMode,
CollectionType = item.CollectionType, CollectionType = item.CollectionType,
Collection = item.Collection, Collection = item.Collection,
MultiCollection = item.MultiCollection,
MediaItem = item.MediaItem, MediaItem = item.MediaItem,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle CustomTitle = item.CustomTitle
}; };
@ -250,6 +270,7 @@
Index = _schedule.Items.Map(i => i.Index).DefaultIfEmpty().Max() + 1, Index = _schedule.Items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
StartType = StartType.Dynamic, StartType = StartType.Dynamic,
PlayoutMode = PlayoutMode.One, PlayoutMode = PlayoutMode.One,
PlaybackOrder = PlaybackOrder.Shuffle,
CollectionType = ProgramScheduleItemCollectionType.Collection CollectionType = ProgramScheduleItemCollectionType.Collection
}; };
@ -284,6 +305,9 @@
private Task<IEnumerable<MediaCollectionViewModel>> SearchMediaCollections(string value) => private Task<IEnumerable<MediaCollectionViewModel>> SearchMediaCollections(string value) =>
_mediaCollections.Filter(c => c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask(); _mediaCollections.Filter(c => c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private Task<IEnumerable<MultiCollectionViewModel>> SearchMultiCollections(string value) =>
_multiCollections.Filter(c => c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionShows(string value) => private Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionShows(string value) =>
_televisionShows.Filter(s => s.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask(); _televisionShows.Filter(s => s.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
@ -302,7 +326,9 @@
item.PlayoutMode, item.PlayoutMode,
item.CollectionType, item.CollectionType,
item.Collection?.Id, item.Collection?.Id,
item.MultiCollection?.Id,
item.MediaItem?.MediaItemId, item.MediaItem?.MediaItemId,
item.PlaybackOrder,
item.MultipleCount, item.MultipleCount,
item.PlayoutDuration, item.PlayoutDuration,
item.PlayoutMode == PlayoutMode.Duration ? item.OfflineTail.IfNone(false) : null, item.PlayoutMode == PlayoutMode.Duration ? item.OfflineTail.IfNone(false) : null,

3
ErsatzTV/Pages/Schedules.razor

@ -19,7 +19,6 @@
<MudText Typo="Typo.h6">Schedules</MudText> <MudText Typo="Typo.h6">Schedules</MudText>
</ToolBarContent> </ToolBarContent>
<ColGroup> <ColGroup>
<col/>
<col/> <col/>
<col style="width: 180px;"/> <col style="width: 180px;"/>
</ColGroup> </ColGroup>
@ -29,12 +28,10 @@
Name Name
</MudTableSortLabel> </MudTableSortLabel>
</MudTh> </MudTh>
<MudTh>Collection Playback Order</MudTh>
<MudTh/> <MudTh/>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd> <MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Name">@context.MediaCollectionPlaybackOrder</MudTd>
<MudTd> <MudTd>
<div style="align-items: center; display: flex;"> <div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Properties"> <MudTooltip Text="Edit Properties">

2
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -197,7 +197,7 @@
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule) if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{ {
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, SeasonId, null, null, null, null)); await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, SeasonId, PlaybackOrder.Shuffle, null, null, null, null));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items"); _navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
} }
} }

2
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -226,7 +226,7 @@
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule) if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{ {
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, ShowId, null, null, null, null)); await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, null, ShowId, PlaybackOrder.Shuffle, null, null, null, null));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items"); _navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
} }
} }

2
ErsatzTV/Shared/AddToCollectionDialog.razor

@ -55,7 +55,7 @@
[Parameter] [Parameter]
public string DetailHighlight { get; set; } public string DetailHighlight { get; set; }
private readonly MediaCollectionViewModel _newCollection = new(-1, "(New Collection)"); private readonly MediaCollectionViewModel _newCollection = new(-1, "(New Collection)", false);
private string _newCollectionName; private string _newCollectionName;
private List<MediaCollectionViewModel> _collections; private List<MediaCollectionViewModel> _collections;

10
ErsatzTV/Validators/CollectionEditViewModelValidator.cs

@ -0,0 +1,10 @@
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators
{
public class CollectionEditViewModelValidator : AbstractValidator<CollectionEditViewModel>
{
public CollectionEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty();
}
}

10
ErsatzTV/Validators/MultiCollectionEditViewModelValidator.cs

@ -0,0 +1,10 @@
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators
{
public class MultiCollectionEditViewModelValidator : AbstractValidator<MultiCollectionEditViewModel>
{
public MultiCollectionEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty();
}
}

10
ErsatzTV/Validators/SimpleMediaCollectionEditViewModelValidator.cs

@ -1,10 +0,0 @@
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators
{
public class SimpleMediaCollectionEditViewModelValidator : AbstractValidator<SimpleMediaCollectionEditViewModel>
{
public SimpleMediaCollectionEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty();
}
}

2
ErsatzTV/ViewModels/SimpleMediaCollectionEditViewModel.cs → ErsatzTV/ViewModels/CollectionEditViewModel.cs

@ -1,6 +1,6 @@
namespace ErsatzTV.ViewModels namespace ErsatzTV.ViewModels
{ {
public class SimpleMediaCollectionEditViewModel public class CollectionEditViewModel
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } public string Name { get; set; }

11
ErsatzTV/ViewModels/MultiCollectionEditViewModel.cs

@ -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; }
}
}

12
ErsatzTV/ViewModels/MultiCollectionItemEditViewModel.cs

@ -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; }
}
}

5
ErsatzTV/ViewModels/ProgramScheduleEditViewModel.cs

@ -7,14 +7,13 @@ namespace ErsatzTV.ViewModels
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public PlaybackOrder MediaCollectionPlaybackOrder { get; set; }
public bool KeepMultiPartEpisodesTogether { get; set; } public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; } public bool TreatCollectionsAsShows { get; set; }
public UpdateProgramSchedule ToUpdate() => public UpdateProgramSchedule ToUpdate() =>
new(Id, Name, MediaCollectionPlaybackOrder, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows); new(Id, Name, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows);
public CreateProgramSchedule ToCreate() => public CreateProgramSchedule ToCreate() =>
new(Name, MediaCollectionPlaybackOrder, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows); new(Name, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows);
} }
} }

11
ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

@ -38,15 +38,23 @@ namespace ErsatzTV.ViewModels
_collectionType = value; _collectionType = value;
Collection = null; Collection = null;
MultiCollection = null;
MediaItem = null; MediaItem = null;
OnPropertyChanged(nameof(Collection)); OnPropertyChanged(nameof(Collection));
OnPropertyChanged(nameof(MultiCollection));
OnPropertyChanged(nameof(MediaItem)); OnPropertyChanged(nameof(MediaItem));
} }
if (_collectionType == ProgramScheduleItemCollectionType.MultiCollection)
{
PlaybackOrder = PlaybackOrder.Shuffle;
}
} }
} }
public MediaCollectionViewModel Collection { get; set; } public MediaCollectionViewModel Collection { get; set; }
public MultiCollectionViewModel MultiCollection { get; set; }
public NamedMediaItemViewModel MediaItem { get; set; } public NamedMediaItemViewModel MediaItem { get; set; }
public string CollectionName => CollectionType switch public string CollectionName => CollectionType switch
@ -55,9 +63,12 @@ namespace ErsatzTV.ViewModels
ProgramScheduleItemCollectionType.TelevisionShow => MediaItem?.Name, ProgramScheduleItemCollectionType.TelevisionShow => MediaItem?.Name,
ProgramScheduleItemCollectionType.TelevisionSeason => MediaItem?.Name, ProgramScheduleItemCollectionType.TelevisionSeason => MediaItem?.Name,
ProgramScheduleItemCollectionType.Artist => MediaItem?.Name, ProgramScheduleItemCollectionType.Artist => MediaItem?.Name,
ProgramScheduleItemCollectionType.MultiCollection => MultiCollection?.Name,
_ => string.Empty _ => string.Empty
}; };
public PlaybackOrder PlaybackOrder { get; set; }
public int? MultipleCount public int? MultipleCount
{ {
get => PlayoutMode == PlayoutMode.Multiple ? _multipleCount : null; get => PlayoutMode == PlayoutMode.Multiple ? _multipleCount : null;

Loading…
Cancel
Save