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. 51
      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. @@ -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/).
## [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
- Fix release notes on home page with `-alpha` suffix
- 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
### Added

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

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollection
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;
}

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

@ -0,0 +1,80 @@ @@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class CreateMultiCollectionHandler :
IRequestHandler<CreateMultiCollection, Either<BaseError, MultiCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, MultiCollectionViewModel>> Handle(
CreateMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<MultiCollectionViewModel> PersistCollection(
TvContext dbContext,
MultiCollection multiCollection)
{
await dbContext.MultiCollections.AddAsync(multiCollection);
await dbContext.SaveChangesAsync();
await dbContext.Entry(multiCollection)
.Collection(c => c.MultiCollectionItems)
.Query()
.Include(i => i.Collection)
.LoadAsync();
return ProjectToViewModel(multiCollection);
}
private static Task<Validation<BaseError, MultiCollection>> Validate(
TvContext dbContext,
CreateMultiCollection request) =>
ValidateName(dbContext, request).MapT(
name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
}).ToList()
});
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
CreateMultiCollection createMultiCollection)
{
List<string> allNames = await dbContext.MultiCollections
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createMultiCollection.NotEmpty(c => c.Name)
.Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createMultiCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique");
return (result1, result2).Apply((_, _) => createMultiCollection.Name);
}
}
}

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

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

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

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class DeleteMultiCollectionHandler : MediatR.IRequestHandler<DeleteMultiCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Unit>> Handle(
DeleteMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, MultiCollection> validation = await MultiCollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, MultiCollection multiCollection)
{
dbContext.MultiCollections.Remove(multiCollection);
return dbContext.SaveChangesAsync().ToUnit();
}
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(
TvContext dbContext,
DeleteMultiCollection request) =>
dbContext.MultiCollections
.SelectOneAsync(c => c.Id, c => c.Id == request.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>($"MultiCollection {request.MultiCollectionId} does not exist."));
}
}

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

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record UpdateMultiCollection
(
int MultiCollectionId,
string Name,
List<UpdateMultiCollectionItem> Items) : MediatR.IRequest<Either<BaseError, Unit>>;
}

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

@ -0,0 +1,127 @@ @@ -0,0 +1,127 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateMultiCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdateMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, MultiCollection c, UpdateMultiCollection request)
{
c.Name = request.Name;
// save name first so playouts don't get rebuild for a name change
await dbContext.SaveChangesAsync();
var toAdd = request.Items
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId))
.Map(
i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
})
.ToList();
var toRemove = c.MultiCollectionItems
.Filter(i => request.Items.All(i2 => i2.CollectionId != i.CollectionId))
.ToList();
// remove items that are no longer present
c.MultiCollectionItems.RemoveAll(toRemove.Contains);
// update existing items
foreach (MultiCollectionItem item in c.MultiCollectionItems)
{
foreach (UpdateMultiCollectionItem incoming in request.Items.Filter(
i => i.CollectionId == item.CollectionId))
{
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
item.PlaybackOrder = incoming.PlaybackOrder;
}
}
// add new items
c.MultiCollectionItems.AddRange(toAdd);
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection(
request.MultiCollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private static async Task<Validation<BaseError, MultiCollection>> Validate(
TvContext dbContext,
UpdateMultiCollection request) =>
(await MultiCollectionMustExist(dbContext, request), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(
TvContext dbContext,
UpdateMultiCollection updateCollection) =>
dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName(TvContext dbContext, UpdateMultiCollection updateMultiCollection)
{
List<string> allNames = await dbContext.MultiCollections
.Filter(mc => mc.Id != updateMultiCollection.MultiCollectionId)
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = updateMultiCollection.NotEmpty(c => c.Name)
.Bind(_ => updateMultiCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(updateMultiCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("MultiCollection name must be unique");
return (result1, result2).Apply((_, _) => updateMultiCollection.Name);
}
}
}

19
ErsatzTV.Application/MediaCollections/Mapper.cs

@ -1,10 +1,25 @@ @@ -1,10 +1,25 @@
using ErsatzTV.Core.Domain;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections
{
internal static class Mapper
{
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
new(collection.Id, collection.Name);
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new(
multiCollection.Id,
multiCollection.Name,
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList());
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
new(
multiCollectionItem.MultiCollectionId,
ProjectToViewModel(multiCollectionItem.Collection),
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder);
}
}

2
ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs

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

10
ErsatzTV.Application/MediaCollections/MultiCollectionItemViewModel.cs

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

6
ErsatzTV.Application/MediaCollections/MultiCollectionViewModel.cs

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

6
ErsatzTV.Application/MediaCollections/PagedMultiCollectionsViewModel.cs

@ -0,0 +1,6 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ -13,7 +13,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail,

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

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

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

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

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

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

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

@ -88,6 +88,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -88,6 +88,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
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;
default:
return BaseError.New("[CollectionType] is invalid");
@ -109,7 +116,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -109,7 +116,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
},
PlayoutMode.One => new ProgramScheduleItemOne
@ -119,7 +128,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -119,7 +128,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
},
PlayoutMode.Multiple => new ProgramScheduleItemMultiple
@ -129,7 +140,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -129,7 +140,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle
},
@ -140,7 +153,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -140,7 +153,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
OfflineTail = item.OfflineTail.GetValueOrDefault(),
CustomTitle = item.CustomTitle

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

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

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

@ -11,6 +11,7 @@ using LanguageExt; @@ -11,6 +11,7 @@ using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.ProgramSchedules.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.ProgramSchedules.Commands
{
@ -61,7 +62,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -61,7 +62,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ReplaceProgramScheduleItems request) =>
ProgramScheduleMustExist(dbContext, request.ProgramScheduleId)
.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(
ReplaceProgramScheduleItems request,
@ -74,5 +76,43 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -74,5 +76,43 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramSchedule programSchedule) =>
request.Items.Map(item => CollectionTypeMustBeValid(item, programSchedule)).Sequence()
.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 @@ -9,7 +9,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
(
int ProgramScheduleId,
string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
}

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

@ -44,15 +44,11 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -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
bool needToRebuildPlayout =
programSchedule.MediaCollectionPlaybackOrder != request.MediaCollectionPlaybackOrder ||
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows;
programSchedule.Name = request.Name;
programSchedule.MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder;
programSchedule.KeepMultiPartEpisodesTogether =
request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
request.KeepMultiPartEpisodesTogether;
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
request.TreatCollectionsAsShows;

17
ErsatzTV.Application/ProgramSchedules/Mapper.cs

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

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs

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

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs

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

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs

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

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs

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

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs

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

5
ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs

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

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

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

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

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

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

@ -4,6 +4,7 @@ using System.Linq; @@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
namespace ErsatzTV.Core.Tests.Fakes
@ -18,7 +19,16 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -18,7 +19,16 @@ namespace ErsatzTV.Core.Tests.Fakes
throw new NotSupportedException();
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>> PlayoutIdsUsingMultiCollection(int multiCollectionId) =>
throw new NotSupportedException();
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
}
}

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

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

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

@ -9,5 +9,7 @@ namespace ErsatzTV.Core.Domain @@ -9,5 +9,7 @@ namespace ErsatzTV.Core.Domain
public bool UseCustomPlaybackOrder { get; set; }
public List<MediaItem> MediaItems { 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 @@ @@ -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 @@ @@ -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 @@ @@ -17,6 +17,7 @@
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.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 SchedulesDetailPageSize => new("pages.schedules.detail_page_size");
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");

2
ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs

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

1
ErsatzTV.Core/Domain/ProgramSchedule.cs

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

3
ErsatzTV.Core/Domain/ProgramScheduleItem.cs

@ -16,5 +16,8 @@ namespace ErsatzTV.Core.Domain @@ -16,5 +16,8 @@ namespace ErsatzTV.Core.Domain
public Collection Collection { get; set; }
public int? MediaItemId { 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 @@ @@ -5,6 +5,7 @@
Collection = 0,
TelevisionShow = 1,
TelevisionSeason = 2,
Artist = 3
Artist = 3,
MultiCollection = 4
}
}

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

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
@ -9,7 +10,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -9,7 +10,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(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>> PlayoutIdsUsingMultiCollection(int multiCollectionId);
Task<bool> IsCustomPlaybackOrder(int collectionId);
}
}

12
ErsatzTV.Core/Scheduling/CollectionWithItems.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Scheduling
{
public record CollectionWithItems(
int CollectionId,
List<MediaItem> MediaItems,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder,
bool UseCustomOrder);
}

35
ErsatzTV.Core/Scheduling/GroupedMediaItem.cs

@ -1,9 +1,40 @@ @@ -1,9 +1,40 @@
using System.Collections.Generic;
using System.Diagnostics;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Scheduling
{
[DebuggerDisplay("{First}")]
public record GroupedMediaItem(MediaItem First, List<MediaItem> Additional);
[DebuggerDisplay("{" + nameof(First) + "}")]
public class GroupedMediaItem
{
public GroupedMediaItem()
{
}
public GroupedMediaItem(MediaItem first, List<MediaItem> additional)
{
First = first;
Additional = additional ?? new List<MediaItem>();
}
public MediaItem First { get; set; }
public List<MediaItem> Additional { get; set; }
public static IList<MediaItem> FlattenGroups(GroupedMediaItem[] copy, int mediaItemCount)
{
var result = new MediaItem[mediaItemCount];
var i = 0;
foreach (GroupedMediaItem group in copy)
{
result[i++] = group.First;
foreach (MediaItem additional in Optional(group.Additional).Flatten())
{
result[i++] = additional;
}
}
return result;
}
}
}

41
ErsatzTV.Core/Scheduling/MultiCollectionGroup.cs

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
using System;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Scheduling
{
public class MultiCollectionGroup : GroupedMediaItem
{
public MultiCollectionGroup(CollectionWithItems collectionWithItems)
{
if (collectionWithItems.UseCustomOrder)
{
if (collectionWithItems.MediaItems.Count > 0)
{
First = collectionWithItems.MediaItems.Head();
Additional = collectionWithItems.MediaItems.Tail().ToList();
}
else
{
throw new InvalidOperationException("Collection has no items");
}
}
else
{
switch (collectionWithItems.PlaybackOrder)
{
case PlaybackOrder.Chronological:
var sortedItems = collectionWithItems.MediaItems.OrderBy(identity, new ChronologicalMediaComparer())
.ToList();
First = sortedItems.Head();
Additional = sortedItems.Tail().ToList();
break;
default:
throw new NotSupportedException(
$"Unsupported MultiCollection PlaybackOrder: {collectionWithItems.PlaybackOrder}");
}
}
}
}
}

26
ErsatzTV.Core/Scheduling/MultiCollectionGrouper.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Scheduling
{
public class MultiCollectionGrouper
{
public static List<GroupedMediaItem> GroupMediaItems(IList<CollectionWithItems> collections)
{
var result = new List<GroupedMediaItem>();
foreach (CollectionWithItems collection in collections)
{
if (collection.ScheduleAsGroup)
{
result.Add(new MultiCollectionGroup(collection));
}
else
{
result.AddRange(collection.MediaItems.Map(i => new GroupedMediaItem { First = i }));
}
}
return result;
}
}
}

18
ErsatzTV.Core/Scheduling/MultiPartEpisodeGrouper.cs

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

51
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

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

15
ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

@ -16,16 +16,11 @@ namespace ErsatzTV.Core.Scheduling @@ -16,16 +16,11 @@ namespace ErsatzTV.Core.Scheduling
private IList<MediaItem> _shuffled;
public ShuffledMediaCollectionEnumerator(
IList<MediaItem> mediaItems,
CollectionEnumeratorState state,
bool keepMultiPartEpisodesTogether,
bool treatCollectionsAsShows)
IList<GroupedMediaItem> mediaItems,
CollectionEnumeratorState state)
{
_mediaItemCount = mediaItems.Count;
_mediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, treatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
_mediaItemCount = mediaItems.Sum(i => 1 + Optional(i.Additional).Flatten().Count());
_mediaItems = mediaItems;
if (state.Index >= _mediaItems.Count)
{
@ -83,7 +78,7 @@ namespace ErsatzTV.Core.Scheduling @@ -83,7 +78,7 @@ namespace ErsatzTV.Core.Scheduling
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 @@ @@ -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 @@ @@ -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 @@ -19,6 +19,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.MultiCollection)
.WithMany()
.HasForeignKey(i => i.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.MediaItem)
.WithMany()
.HasForeignKey(i => i.MediaItemId)

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

@ -21,6 +21,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -21,6 +21,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.HasForeignKey(i => i.MediaItemId)
.OnDelete(DeleteBehavior.Cascade)
.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; @@ -5,6 +5,8 @@ using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
@ -50,16 +52,113 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -50,16 +52,113 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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) =>
_dbConnection.QueryAsync<int>(
@"SELECT DISTINCT p.Id
FROM Playout p
INNER JOIN ProgramSchedule PS on p.ProgramScheduleId = PS.Id
INNER JOIN ProgramScheduleItem PSI on p.Anchor_NextScheduleItemId = PSI.Id
WHERE PSI.CollectionId = @CollectionId",
@"SELECT DISTINCT p.PlayoutId
FROM PlayoutProgramScheduleAnchor p
WHERE p.CollectionId = @CollectionId",
new { CollectionId = collectionId })
.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) =>
_dbConnection.QuerySingleAsync<bool>(
@"SELECT IFNULL(MIN(UseCustomPlaybackOrder), 0) FROM Collection WHERE Id = @CollectionId",

1
ErsatzTV.Infrastructure/Data/TvContext.cs

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

58
ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs

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

2
ErsatzTV/Pages/Artist.razor

@ -206,7 +206,7 @@ @@ -206,7 +206,7 @@
DialogResult result = await dialog.Result;
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");
}
}

2
ErsatzTV/Pages/CollectionEditor.razor

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

118
ErsatzTV/Pages/Collections.razor

@ -9,11 +9,20 @@ @@ -9,11 +9,20 @@
@inject IMediator _mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true"
@bind-RowsPerPage="@_rowsPerPage"
ServerData="@(new Func<TableState, Task<TableData<MediaCollectionViewModel>>>(ServerReload))"
<div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/media/collections/add">
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"
@ref="_table">
@ref="_collectionsTable">
<ToolBarContent>
<MudText Typo="Typo.h6">Collections</MudText>
</ToolBarContent>
@ -46,38 +55,91 @@ @@ -46,38 +55,91 @@
<MudTablePager/>
</PagerContent>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/media/collections/add" Class="mt-4">
Add Collection
</MudButton>
<MudTable Class="mt-4"
Hover="true"
@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>
@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))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
protected override async Task OnParametersSetAsync()
{
_collectionsRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.CollectionsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
private async Task DeleteMediaCollection(MediaCardViewModel vm)
_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(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);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
await _mediator.Send(new DeleteCollection(collection.Id));
await _table.ReloadServerData();
}
IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
await _mediator.Send(new DeleteCollection(collection.Id));
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 };
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>> ServerReload(TableState state)
private async Task<TableData<MediaCollectionViewModel>> ServerReloadCollections(TableState state)
{
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.CollectionsPageSize, state.PageSize.ToString()));
@ -85,4 +147,12 @@ @@ -85,4 +147,12 @@
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 @@ @@ -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 @@ @@ -16,16 +16,9 @@
<MudCard>
<MudCardContent>
<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">
<MudCheckBox Label="Keep Multi-Part Episodes Together"
@bind-Checked="@_model.KeepMultiPartEpisodesTogether"
Disabled="@(_model.MediaCollectionPlaybackOrder != PlaybackOrder.Shuffle)"
For="@(() => _model.KeepMultiPartEpisodesTogether)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
@ -66,7 +59,6 @@ @@ -66,7 +59,6 @@
{
_model.Id = viewModel.Id;
_model.Name = viewModel.Name;
_model.MediaCollectionPlaybackOrder = viewModel.MediaCollectionPlaybackOrder;
_model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether;
_model.TreatCollectionsAsShows = viewModel.TreatCollectionsAsShows;
},
@ -75,7 +67,6 @@ @@ -75,7 +67,6 @@
else
{
_model.Name = "New Schedule";
_model.MediaCollectionPlaybackOrder = PlaybackOrder.Shuffle;
}
}

26
ErsatzTV/Pages/ScheduleItemsEditor.razor

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

3
ErsatzTV/Pages/Schedules.razor

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

2
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -197,7 +197,7 @@ @@ -197,7 +197,7 @@
DialogResult result = await dialog.Result;
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");
}
}

2
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -226,7 +226,7 @@ @@ -226,7 +226,7 @@
DialogResult result = await dialog.Result;
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");
}
}

2
ErsatzTV/Shared/AddToCollectionDialog.razor

@ -55,7 +55,7 @@ @@ -55,7 +55,7 @@
[Parameter]
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 List<MediaCollectionViewModel> _collections;

10
ErsatzTV/Validators/CollectionEditViewModelValidator.cs

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

10
ErsatzTV/Validators/MultiCollectionEditViewModelValidator.cs

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

10
ErsatzTV/Validators/SimpleMediaCollectionEditViewModelValidator.cs

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

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

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

11
ErsatzTV/ViewModels/MultiCollectionEditViewModel.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace ErsatzTV.ViewModels
{
public class MultiCollectionEditViewModel
{
public int Id { get; set; }
public string Name { get; set; }
public List<MultiCollectionItemEditViewModel> Items { get; set; }
}
}

12
ErsatzTV/ViewModels/MultiCollectionItemEditViewModel.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.ViewModels
{
public class MultiCollectionItemEditViewModel
{
public MediaCollectionViewModel Collection { get; set; }
public bool ScheduleAsGroup { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
}
}

5
ErsatzTV/ViewModels/ProgramScheduleEditViewModel.cs

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

11
ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

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

Loading…
Cancel
Save