Browse Source

allow shuffling block items (#1571)

* allow shuffling block items

* fix drop down search results
pull/1572/head
Jason Dove 2 years ago committed by GitHub
parent
commit
12234c3e21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 5
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  3. 11
      ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs
  4. 6
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  5. 17
      ErsatzTV.Application/FFmpegProfiles/Commands/CopyFFmpegProfileHandler.cs
  6. 10
      ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs
  7. 12
      ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs
  8. 12
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs
  9. 0
      ErsatzTV.Application/MediaCollections/Commands/AddMovieToCollection.cs
  10. 14
      ErsatzTV.Application/MediaCollections/Commands/CreateCollectionHandler.cs
  11. 12
      ErsatzTV.Application/MediaCollections/Commands/CreateMultiCollectionHandler.cs
  12. 12
      ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs
  13. 16
      ErsatzTV.Application/MediaCollections/Commands/DeleteCollectionHandler.cs
  14. 15
      ErsatzTV.Application/MediaCollections/Commands/DeleteMultiCollectionHandler.cs
  15. 16
      ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs
  16. 10
      ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionHandler.cs
  17. 8
      ErsatzTV.Application/MediaCollections/Commands/UpdateMultiCollectionHandler.cs
  18. 10
      ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs
  19. 11
      ErsatzTV.Application/Watermarks/Commands/CopyWatermarkHandler.cs
  20. 12
      ErsatzTV.Application/Watermarks/Commands/CreateWatermarkHandler.cs
  21. 12
      ErsatzTV.Application/Watermarks/Commands/DeleteWatermarkHandler.cs
  22. 10
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs
  23. 2
      ErsatzTV.Core/Domain/Scheduling/PlayoutHistory.cs
  24. 8
      ErsatzTV.Core/Interfaces/Search/ISearchTargets.cs
  25. 63
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs
  26. 152
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs
  27. 106
      ErsatzTV.Core/Scheduling/MaskedShuffledMediaCollectionEnumerator.cs
  28. 4922
      ErsatzTV.Infrastructure.MySql/Migrations/20240124041658_Add_PlayoutHistorySeed.Designer.cs
  29. 40
      ErsatzTV.Infrastructure.MySql/Migrations/20240124041658_Add_PlayoutHistorySeed.cs
  30. 6
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  31. 4917
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240124020453_Add_PlayoutHistoryPlaybackOrder.Designer.cs
  32. 29
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240124020453_Add_PlayoutHistoryPlaybackOrder.cs
  33. 4920
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240124035210_Add_PlayoutHistorySeed.Designer.cs
  34. 29
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240124035210_Add_PlayoutHistorySeed.cs
  35. 6
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  36. 13
      ErsatzTV.Infrastructure/Search/SearchTargets.cs
  37. 10
      ErsatzTV/Pages/BlockEditor.razor
  38. 11
      ErsatzTV/Shared/MainLayout.razor
  39. 1
      ErsatzTV/Startup.cs

1
CHANGELOG.md

@ -41,6 +41,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -41,6 +41,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Re-uploading images should fix them
- Recreate XMLTV channel list (including logos) when channels are edited in ErsatzTV
- This bug caused the ErsatzTV logo to be used instead of channel logos in some cases
- Update drop down search results in main search bar when items are created/edited/removed
### Changed
- Upgrade from .NET 7 to .NET 8

5
ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs

@ -4,6 +4,7 @@ using System.Threading.Channels; @@ -4,6 +4,7 @@ using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -13,7 +14,8 @@ namespace ErsatzTV.Application.Channels; @@ -13,7 +14,8 @@ namespace ErsatzTV.Application.Channels;
public class CreateChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory)
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets)
: IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
{
public async Task<Either<BaseError, CreateChannelResult>> Handle(
@ -29,6 +31,7 @@ public class CreateChannelHandler( @@ -29,6 +31,7 @@ public class CreateChannelHandler(
{
await dbContext.Channels.AddAsync(channel);
await dbContext.SaveChangesAsync();
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(new RefreshChannelList());
return new CreateChannelResult(channel.Id);
}

11
ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -12,30 +13,34 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr @@ -12,30 +13,34 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly ISearchTargets _searchTargets;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public DeleteChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem)
ILocalFileSystem localFileSystem,
ISearchTargets searchTargets)
{
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request);
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c, cancellationToken));
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
}
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
{
dbContext.Channels.Remove(channel);
await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
// delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");

6
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs

@ -4,6 +4,7 @@ using System.Threading.Channels; @@ -4,6 +4,7 @@ using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -14,7 +15,8 @@ namespace ErsatzTV.Application.Channels; @@ -14,7 +15,8 @@ namespace ErsatzTV.Application.Channels;
public class UpdateChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory)
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets)
: IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{
public async Task<Either<BaseError, ChannelViewModel>> Handle(
@ -69,6 +71,8 @@ public class UpdateChannelHandler( @@ -69,6 +71,8 @@ public class UpdateChannelHandler(
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync();
searchTargets.SearchTargetsChanged();
if (c.SubtitleMode != ChannelSubtitleMode.None)
{

17
ErsatzTV.Application/FFmpegProfiles/Commands/CopyFFmpegProfileHandler.cs

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles;
@ -8,9 +10,13 @@ public class @@ -8,9 +10,13 @@ public class
CopyFFmpegProfileHandler : IRequestHandler<CopyFFmpegProfile, Either<BaseError, FFmpegProfileViewModel>>
{
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
private readonly ISearchTargets _searchTargets;
public CopyFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository) =>
public CopyFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository, ISearchTargets searchTargets)
{
_ffmpegProfileRepository = ffmpegProfileRepository;
_searchTargets = searchTargets;
}
public Task<Either<BaseError, FFmpegProfileViewModel>> Handle(
CopyFFmpegProfile request,
@ -19,9 +25,12 @@ public class @@ -19,9 +25,12 @@ public class
.MapT(PerformCopy)
.Bind(v => v.ToEitherAsync());
private Task<FFmpegProfileViewModel> PerformCopy(CopyFFmpegProfile request) =>
_ffmpegProfileRepository.Copy(request.FFmpegProfileId, request.Name)
.Map(ProjectToViewModel);
private async Task<FFmpegProfileViewModel> PerformCopy(CopyFFmpegProfile request)
{
FFmpegProfile copy = await _ffmpegProfileRepository.Copy(request.FFmpegProfileId, request.Name);
_searchTargets.SearchTargetsChanged();
return ProjectToViewModel(copy);
}
private static Task<Validation<BaseError, CopyFFmpegProfile>> Validate(CopyFFmpegProfile request) =>
ValidateName(request).AsTask().MapT(_ => request);

10
ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -10,9 +11,13 @@ public class CreateFFmpegProfileHandler : @@ -10,9 +11,13 @@ public class CreateFFmpegProfileHandler :
IRequestHandler<CreateFFmpegProfile, Either<BaseError, CreateFFmpegProfileResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public CreateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public CreateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, CreateFFmpegProfileResult>> Handle(
CreateFFmpegProfile request,
@ -23,12 +28,13 @@ public class CreateFFmpegProfileHandler : @@ -23,12 +28,13 @@ public class CreateFFmpegProfileHandler :
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
}
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
private async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
TvContext dbContext,
FFmpegProfile ffmpegProfile)
{
await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
}

12
ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -9,23 +10,28 @@ namespace ErsatzTV.Application.FFmpegProfiles; @@ -9,23 +10,28 @@ namespace ErsatzTV.Application.FFmpegProfiles;
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public DeleteFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteFFmpegProfile request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
return await validation.Apply(p => DoDeletion(dbContext, p));
}
private static async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
private async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
{
dbContext.FFmpegProfiles.Remove(ffmpegProfile);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}

12
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -10,9 +11,13 @@ public class @@ -10,9 +11,13 @@ public class
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
UpdateFFmpegProfile request,
@ -23,7 +28,7 @@ public class @@ -23,7 +28,7 @@ public class
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
}
private static async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
TvContext dbContext,
FFmpegProfile p,
UpdateFFmpegProfile update)
@ -54,6 +59,9 @@ public class @@ -54,6 +59,9 @@ public class
p.NormalizeFramerate = update.NormalizeFramerate;
p.DeinterlaceVideo = update.DeinterlaceVideo;
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return new UpdateFFmpegProfileResult(p.Id);
}

0
ErsatzTV.Application/MediaCollections/Commands/AddMovieCollection.cs → ErsatzTV.Application/MediaCollections/Commands/AddMovieToCollection.cs

14
ErsatzTV.Application/MediaCollections/Commands/CreateCollectionHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@ -10,25 +11,28 @@ public class CreateCollectionHandler : @@ -10,25 +11,28 @@ public class CreateCollectionHandler :
IRequestHandler<CreateCollection, Either<BaseError, MediaCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public CreateCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public CreateCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, MediaCollectionViewModel>> Handle(
CreateCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<MediaCollectionViewModel> PersistCollection(
TvContext dbContext,
Collection collection)
private async Task<MediaCollectionViewModel> PersistCollection(TvContext dbContext, Collection collection)
{
await dbContext.Collections.AddAsync(collection);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return ProjectToViewModel(collection);
}

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

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@ -10,25 +11,30 @@ public class CreateMultiCollectionHandler : @@ -10,25 +11,30 @@ public class CreateMultiCollectionHandler :
IRequestHandler<CreateMultiCollection, Either<BaseError, MultiCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public CreateMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public CreateMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, MultiCollectionViewModel>> Handle(
CreateMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<MultiCollectionViewModel> PersistCollection(
private async Task<MultiCollectionViewModel> PersistCollection(
TvContext dbContext,
MultiCollection multiCollection)
{
await dbContext.MultiCollections.AddAsync(multiCollection);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
await dbContext.Entry(multiCollection)
.Collection(c => c.MultiCollectionItems)
.Query()

12
ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@ -10,25 +11,30 @@ public class CreateSmartCollectionHandler : @@ -10,25 +11,30 @@ public class CreateSmartCollectionHandler :
IRequestHandler<CreateSmartCollection, Either<BaseError, SmartCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public CreateSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public CreateSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, SmartCollectionViewModel>> Handle(
CreateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<SmartCollectionViewModel> PersistCollection(
private async Task<SmartCollectionViewModel> PersistCollection(
TvContext dbContext,
SmartCollection smartCollection)
{
await dbContext.SmartCollections.AddAsync(smartCollection);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return ProjectToViewModel(smartCollection);
}

16
ErsatzTV.Application/MediaCollections/Commands/DeleteCollectionHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -9,24 +10,29 @@ namespace ErsatzTV.Application.MediaCollections; @@ -9,24 +10,29 @@ namespace ErsatzTV.Application.MediaCollections;
public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public DeleteCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await CollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, Collection collection)
private async Task<Unit> DoDeletion(TvContext dbContext, Collection collection)
{
dbContext.Collections.Remove(collection);
return dbContext.SaveChangesAsync().ToUnit();
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}
private static Task<Validation<BaseError, Collection>> CollectionMustExist(

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

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -9,24 +10,30 @@ namespace ErsatzTV.Application.MediaCollections; @@ -9,24 +10,30 @@ namespace ErsatzTV.Application.MediaCollections;
public class DeleteMultiCollectionHandler : IRequestHandler<DeleteMultiCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public DeleteMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteMultiCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
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)
private async Task<Unit> DoDeletion(TvContext dbContext, MultiCollection multiCollection)
{
dbContext.MultiCollections.Remove(multiCollection);
return dbContext.SaveChangesAsync().ToUnit();
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(

16
ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -9,24 +10,29 @@ namespace ErsatzTV.Application.MediaCollections; @@ -9,24 +10,29 @@ namespace ErsatzTV.Application.MediaCollections;
public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public DeleteSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
private async Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
{
dbContext.SmartCollections.Remove(smartCollection);
return dbContext.SaveChangesAsync().ToUnit();
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(

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

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts; @@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@ -13,17 +14,20 @@ namespace ErsatzTV.Application.MediaCollections; @@ -13,17 +14,20 @@ namespace ErsatzTV.Application.MediaCollections;
public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly ISearchTargets _searchTargets;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
@ -32,7 +36,7 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either< @@ -32,7 +36,7 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, Collection c, UpdateCollection request)
@ -52,6 +56,8 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either< @@ -52,6 +56,8 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}

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

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts; @@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@ -13,17 +14,20 @@ namespace ErsatzTV.Application.MediaCollections; @@ -13,17 +14,20 @@ namespace ErsatzTV.Application.MediaCollections;
public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly ISearchTargets _searchTargets;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateMultiCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
@ -115,6 +119,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio @@ -115,6 +119,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
_searchTargets.SearchTargetsChanged();
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection(
request.MultiCollectionId))

10
ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts; @@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@ -13,17 +14,20 @@ namespace ErsatzTV.Application.MediaCollections; @@ -13,17 +14,20 @@ namespace ErsatzTV.Application.MediaCollections;
public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly ISearchTargets _searchTargets;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateSmartCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
@ -32,7 +36,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio @@ -32,7 +36,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, SmartCollection c, UpdateSmartCollection request)
@ -42,6 +46,8 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio @@ -42,6 +46,8 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
_searchTargets.SearchTargetsChanged();
// refresh all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
{

11
ErsatzTV.Application/Watermarks/Commands/CopyWatermarkHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
@ -11,9 +12,13 @@ public class CopyWatermarkHandler : @@ -11,9 +12,13 @@ public class CopyWatermarkHandler :
IRequestHandler<CopyWatermark, Either<BaseError, WatermarkViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public CopyWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public CopyWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public Task<Either<BaseError, WatermarkViewModel>> Handle(
CopyWatermark request,
@ -24,7 +29,7 @@ public class CopyWatermarkHandler : @@ -24,7 +29,7 @@ public class CopyWatermarkHandler :
private async Task<WatermarkViewModel> PerformCopy(CopyWatermark request)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
ChannelWatermark channelWatermark = await dbContext.ChannelWatermarks.FindAsync(request.WatermarkId);
PropertyValues values = dbContext.Entry(channelWatermark).CurrentValues.Clone();
@ -36,6 +41,8 @@ public class CopyWatermarkHandler : @@ -36,6 +41,8 @@ public class CopyWatermarkHandler :
clone.Name = request.Name;
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return ProjectToViewModel(clone);
}

12
ErsatzTV.Application/Watermarks/Commands/CreateWatermarkHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@ -8,25 +9,30 @@ namespace ErsatzTV.Application.Watermarks; @@ -8,25 +9,30 @@ namespace ErsatzTV.Application.Watermarks;
public class CreateWatermarkHandler : IRequestHandler<CreateWatermark, Either<BaseError, CreateWatermarkResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public CreateWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public CreateWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, CreateWatermarkResult>> Handle(
CreateWatermark request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ChannelWatermark> validation = Validate(request);
return await validation.Apply(profile => PersistChannelWatermark(dbContext, profile));
}
private static async Task<CreateWatermarkResult> PersistChannelWatermark(
private async Task<CreateWatermarkResult> PersistChannelWatermark(
TvContext dbContext,
ChannelWatermark watermark)
{
await dbContext.ChannelWatermarks.AddAsync(watermark);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return new CreateWatermarkResult(watermark.Id);
}

12
ErsatzTV.Application/Watermarks/Commands/DeleteWatermarkHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -9,25 +10,30 @@ namespace ErsatzTV.Application.Watermarks; @@ -9,25 +10,30 @@ namespace ErsatzTV.Application.Watermarks;
public class DeleteWatermarkHandler : IRequestHandler<DeleteWatermark, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public DeleteWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public DeleteWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteWatermark request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ChannelWatermark> validation = await WatermarkMustExist(dbContext, request);
return await validation.Apply(p => DoDeletion(dbContext, p));
}
private static async Task<Unit> DoDeletion(TvContext dbContext, ChannelWatermark watermark)
private async Task<Unit> DoDeletion(TvContext dbContext, ChannelWatermark watermark)
{
await dbContext.Database.ExecuteSqlAsync(
$"UPDATE Channel SET WatermarkId = NULL WHERE WatermarkId = {watermark.Id}");
dbContext.ChannelWatermarks.Remove(watermark);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}

10
ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -9,9 +10,13 @@ namespace ErsatzTV.Application.Watermarks; @@ -9,9 +10,13 @@ namespace ErsatzTV.Application.Watermarks;
public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<BaseError, UpdateWatermarkResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public UpdateWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public UpdateWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, UpdateWatermarkResult>> Handle(
UpdateWatermark request,
@ -22,7 +27,7 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba @@ -22,7 +27,7 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
}
private static async Task<UpdateWatermarkResult> ApplyUpdateRequest(
private async Task<UpdateWatermarkResult> ApplyUpdateRequest(
TvContext dbContext,
ChannelWatermark p,
UpdateWatermark update)
@ -41,6 +46,7 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba @@ -41,6 +46,7 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
p.Opacity = update.Opacity;
p.PlaceWithinSourceContent = update.PlaceWithinSourceContent;
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
return new UpdateWatermarkResult(p.Id);
}

2
ErsatzTV.Core/Domain/Scheduling/PlayoutHistory.cs

@ -9,6 +9,8 @@ public class PlayoutHistory @@ -9,6 +9,8 @@ public class PlayoutHistory
public int BlockId { get; set; }
public Block Block { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
public int Seed { get; set; }
// something that uniquely identifies the collection within the block
public string Key { get; set; }

8
ErsatzTV.Core/Interfaces/Search/ISearchTargets.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Interfaces.Search;
public interface ISearchTargets
{
event EventHandler OnSearchTargetsChanged;
void SearchTargetsChanged();
}

63
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs

@ -36,6 +36,7 @@ public class BlockPlayoutBuilder( @@ -36,6 +36,7 @@ public class BlockPlayoutBuilder(
[
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Shuffle,
PlaybackOrder.Random
];
@ -177,6 +178,8 @@ public class BlockPlayoutBuilder( @@ -177,6 +178,8 @@ public class BlockPlayoutBuilder(
{
PlayoutId = playout.Id,
BlockId = blockItem.BlockId,
PlaybackOrder = blockItem.PlaybackOrder,
Seed = enumerator.State.Seed,
When = currentTime.UtcDateTime,
Key = historyKey,
Details = HistoryDetails.ForMediaItem(mediaItem)
@ -212,41 +215,37 @@ public class BlockPlayoutBuilder( @@ -212,41 +215,37 @@ public class BlockPlayoutBuilder(
string historyKey,
Map<CollectionKey, List<MediaItem>> collectionMediaItems)
{
DateTime historyTime = currentTime.UtcDateTime;
Option<PlayoutHistory> maybeHistory = playout.PlayoutHistory
.Filter(h => h.BlockId == blockItem.BlockId)
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.OrderByDescending(h => h.When)
.HeadOrNone();
var state = new CollectionEnumeratorState { Seed = 0, Index = 0 };
var collectionKey = CollectionKey.ForBlockItem(blockItem);
List<MediaItem> collectionItems = collectionMediaItems[collectionKey];
// get enumerator
IMediaCollectionEnumerator enumerator = blockItem.PlaybackOrder switch
{
PlaybackOrder.Chronological => new ChronologicalMediaCollectionEnumerator(collectionItems, state),
PlaybackOrder.SeasonEpisode => new SeasonEpisodeMediaCollectionEnumerator(collectionItems, state),
PlaybackOrder.Chronological => BlockPlayoutEnumerator.Chronological(
collectionItems,
currentTime,
playout,
blockItem,
historyKey,
Logger),
PlaybackOrder.SeasonEpisode => BlockPlayoutEnumerator.SeasonEpisode(
collectionItems,
currentTime,
playout,
blockItem,
historyKey,
Logger),
PlaybackOrder.Shuffle => BlockPlayoutEnumerator.Shuffle(
collectionItems,
currentTime,
playout,
blockItem,
historyKey),
_ => new RandomizedMediaCollectionEnumerator(
collectionItems,
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 })
};
// seek to the appropriate place in the collection enumerator
foreach (PlayoutHistory history in maybeHistory)
{
Logger.LogDebug("History is applicable: {When}: {History}", history.When, history.Details);
HistoryDetails.MoveToNextItem(
collectionItems,
history.Details,
enumerator,
blockItem.PlaybackOrder);
}
return enumerator;
}
@ -288,8 +287,20 @@ public class BlockPlayoutBuilder( @@ -288,8 +287,20 @@ public class BlockPlayoutBuilder(
IEnumerable<PlayoutHistory> toDelete = group
.Filter(h => h.When < start.UtcDateTime)
.OrderByDescending(h => h.When)
.Tail();
.OrderByDescending(h => h.When);
// chronological and season, episode only need to keep most recent entry
if (group.Count > 0 && group[0].PlaybackOrder is PlaybackOrder.Chronological or PlaybackOrder.SeasonEpisode)
{
toDelete = toDelete.Tail();
}
// shuffle needs to keep all entries with current seed
if (group.Count > 0 && group[0].PlaybackOrder is PlaybackOrder.Shuffle)
{
int currentSeed = group[0].Seed;
toDelete = toDelete.Filter(h => h.Seed != currentSeed);
}
foreach (PlayoutHistory delete in toDelete)
{

152
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs

@ -0,0 +1,152 @@ @@ -0,0 +1,152 @@
using System.Collections.Immutable;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Scheduling;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace ErsatzTV.Core.Scheduling.BlockScheduling;
public static class BlockPlayoutEnumerator
{
public static IMediaCollectionEnumerator Chronological(
List<MediaItem> collectionItems,
DateTimeOffset currentTime,
Playout playout,
BlockItem blockItem,
string historyKey,
ILogger logger)
{
DateTime historyTime = currentTime.UtcDateTime;
Option<PlayoutHistory> maybeHistory = playout.PlayoutHistory
.Filter(h => h.BlockId == blockItem.BlockId)
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.OrderByDescending(h => h.When)
.HeadOrNone();
var state = new CollectionEnumeratorState { Seed = 0, Index = 0 };
var enumerator = new ChronologicalMediaCollectionEnumerator(collectionItems, state);
// seek to the appropriate place in the collection enumerator
foreach (PlayoutHistory h in maybeHistory)
{
logger.LogDebug("History is applicable: {When}: {History}", h.When, h.Details);
HistoryDetails.MoveToNextItem(
collectionItems,
h.Details,
enumerator,
blockItem.PlaybackOrder);
}
return enumerator;
}
public static IMediaCollectionEnumerator SeasonEpisode(
List<MediaItem> collectionItems,
DateTimeOffset currentTime,
Playout playout,
BlockItem blockItem,
string historyKey,
ILogger logger)
{
DateTime historyTime = currentTime.UtcDateTime;
Option<PlayoutHistory> maybeHistory = playout.PlayoutHistory
.Filter(h => h.BlockId == blockItem.BlockId)
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.OrderByDescending(h => h.When)
.HeadOrNone();
var state = new CollectionEnumeratorState { Seed = 0, Index = 0 };
var enumerator = new SeasonEpisodeMediaCollectionEnumerator(collectionItems, state);
// seek to the appropriate place in the collection enumerator
foreach (PlayoutHistory h in maybeHistory)
{
logger.LogDebug("History is applicable: {When}: {History}", h.When, h.Details);
HistoryDetails.MoveToNextItem(
collectionItems,
h.Details,
enumerator,
blockItem.PlaybackOrder);
}
return enumerator;
}
public static IMediaCollectionEnumerator Shuffle(
List<MediaItem> collectionItems,
DateTimeOffset currentTime,
Playout playout,
BlockItem blockItem,
string historyKey)
{
// need a new shuffled media collection enumerator that can "hide" items for one iteration, then include all items again
// maybe take a "masked items" hash set, then clear it after shuffling
DateTime historyTime = currentTime.UtcDateTime;
var maskedMediaItemIds = new System.Collections.Generic.HashSet<int>();
List<PlayoutHistory> history = playout.PlayoutHistory
.Filter(h => h.BlockId == blockItem.BlockId)
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.OrderByDescending(h => h.When)
.ToList();
if (history.Count > 0)
{
int currentSeed = history[0].Seed;
history = history.Filter(h => h.Seed == currentSeed).ToList();
}
var knownMediaIds = collectionItems.Map(ci => ci.Id).ToImmutableHashSet();
foreach (PlayoutHistory h in history)
{
HistoryDetails.Details details = JsonConvert.DeserializeObject<HistoryDetails.Details>(h.Details);
foreach (int mediaItemId in Optional(details.MediaItemId))
{
if (knownMediaIds.Contains(mediaItemId))
{
maskedMediaItemIds.Add(mediaItemId);
}
}
}
var state = new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 };
// keep the current seed if one exists
if (maskedMediaItemIds.Count > 0 && maskedMediaItemIds.Count < collectionItems.Count && history.Count > 0)
{
state.Seed = history[0].Seed;
}
// if everything is masked, nothing is masked
if (maskedMediaItemIds.Count == collectionItems.Count)
{
maskedMediaItemIds.Clear();
}
// TODO: fix multi-collection groups, keep multi-part episodes together
var mediaItems = collectionItems
.Map(mi => new GroupedMediaItem(mi, null))
.ToList();
Serilog.Log.Logger.Debug(
"scheduling {X} media items with {Y} masked",
mediaItems.Count,
maskedMediaItemIds.Count);
// it shouldn't matter which order the remaining items are shuffled in,
// as long as already-played items are not included
return new MaskedShuffledMediaCollectionEnumerator(
mediaItems,
maskedMediaItemIds,
state,
CancellationToken.None);
}
}

106
ErsatzTV.Core/Scheduling/MaskedShuffledMediaCollectionEnumerator.cs

@ -0,0 +1,106 @@ @@ -0,0 +1,106 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling;
public class MaskedShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
{
private readonly CancellationToken _cancellationToken;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly int _mediaItemCount;
private readonly IList<GroupedMediaItem> _mediaItems;
private CloneableRandom _random;
private IList<MediaItem> _shuffled;
public MaskedShuffledMediaCollectionEnumerator(
IList<GroupedMediaItem> mediaItems,
IReadOnlySet<int> maskedMediaItemIds,
CollectionEnumeratorState state,
CancellationToken cancellationToken)
{
_mediaItemCount = mediaItems.Sum(i => 1 + Optional(i.Additional).Flatten().Count());
_mediaItems = mediaItems;
_cancellationToken = cancellationToken;
if (state.Index >= _mediaItems.Count)
{
state.Index = 0;
state.Seed = new Random(state.Seed).Next();
}
_random = new CloneableRandom(state.Seed);
// remove masked items from initial shuffle
var filtered = _mediaItems.Filter(mi => !maskedMediaItemIds.Contains(mi.First.Id)).ToList();
foreach (GroupedMediaItem group in filtered)
{
group.Additional.RemoveAll(mi => maskedMediaItemIds.Contains(mi.Id));
}
_shuffled = Shuffle(filtered, _random);
_lazyMinimumDuration =
new Lazy<Option<TimeSpan>>(
() => _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = state;
}
public void ResetState(CollectionEnumeratorState state)
{
// only re-shuffle if needed
if (State.Seed != state.Seed)
{
_random = new CloneableRandom(state.Seed);
_shuffled = Shuffle(_mediaItems, _random);
}
State.Index = state.Index;
}
public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None;
public void MoveNext()
{
if ((State.Index + 1) % _mediaItemCount == 0)
{
Option<MediaItem> tail = Current;
State.Index = 0;
do
{
State.Seed = _random.Next();
_random = new CloneableRandom(State.Seed);
_shuffled = Shuffle(_mediaItems, _random);
} while (!_cancellationToken.IsCancellationRequested && _mediaItems.Count > 1 &&
Current.Map(x => x.Id) == tail.Map(x => x.Id));
}
else
{
State.Index++;
}
State.Index %= _mediaItemCount;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
private IList<MediaItem> Shuffle(IEnumerable<GroupedMediaItem> list, CloneableRandom random)
{
GroupedMediaItem[] copy = list.ToArray();
int n = copy.Length;
while (n > 1)
{
n--;
int k = random.Next(n + 1);
(copy[k], copy[n]) = (copy[n], copy[k]);
}
return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount);
}
}

4922
ErsatzTV.Infrastructure.MySql/Migrations/20240124041658_Add_PlayoutHistorySeed.Designer.cs generated

File diff suppressed because it is too large Load Diff

40
ErsatzTV.Infrastructure.MySql/Migrations/20240124041658_Add_PlayoutHistorySeed.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutHistorySeed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PlaybackOrder",
table: "PlayoutHistory",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "Seed",
table: "PlayoutHistory",
type: "int",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PlaybackOrder",
table: "PlayoutHistory");
migrationBuilder.DropColumn(
name: "Seed",
table: "PlayoutHistory");
}
}
}

6
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -1937,9 +1937,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1937,9 +1937,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Key")
.HasColumnType("longtext");
b.Property<int>("PlaybackOrder")
.HasColumnType("int");
b.Property<int>("PlayoutId")
.HasColumnType("int");
b.Property<int>("Seed")
.HasColumnType("int");
b.Property<DateTime>("When")
.HasColumnType("datetime(6)");

4917
ErsatzTV.Infrastructure.Sqlite/Migrations/20240124020453_Add_PlayoutHistoryPlaybackOrder.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.Sqlite/Migrations/20240124020453_Add_PlayoutHistoryPlaybackOrder.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutHistoryPlaybackOrder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PlaybackOrder",
table: "PlayoutHistory",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PlaybackOrder",
table: "PlayoutHistory");
}
}
}

4920
ErsatzTV.Infrastructure.Sqlite/Migrations/20240124035210_Add_PlayoutHistorySeed.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.Sqlite/Migrations/20240124035210_Add_PlayoutHistorySeed.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutHistorySeed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Seed",
table: "PlayoutHistory",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Seed",
table: "PlayoutHistory");
}
}
}

6
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -1935,9 +1935,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1935,9 +1935,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int>("PlaybackOrder")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<int>("Seed")
.HasColumnType("INTEGER");
b.Property<DateTime>("When")
.HasColumnType("TEXT");

13
ErsatzTV.Infrastructure/Search/SearchTargets.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using ErsatzTV.Core.Interfaces.Search;
namespace ErsatzTV.Infrastructure.Search;
public class SearchTargets : ISearchTargets
{
public event EventHandler OnSearchTargetsChanged;
public void SearchTargetsChanged()
{
OnSearchTargetsChanged?.Invoke(this, EventArgs.Empty);
}
}

10
ErsatzTV/Pages/BlockEditor.razor

@ -208,21 +208,21 @@ @@ -208,21 +208,21 @@
@switch (_selectedItem.CollectionType)
{
case ProgramScheduleItemCollectionType.MultiCollection:
@* <MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem> *@
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> *@
break;
case ProgramScheduleItemCollectionType.Collection:
case ProgramScheduleItemCollectionType.SmartCollection:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem> *@
@* <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> *@
break;
case ProgramScheduleItemCollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem> *@
@* <MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem> *@
break;
case ProgramScheduleItemCollectionType.TelevisionSeason:
@ -230,8 +230,8 @@ @@ -230,8 +230,8 @@
case ProgramScheduleItemCollectionType.FakeCollection:
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem> *@
break;
}
</MudSelect>

11
ErsatzTV/Shared/MainLayout.razor

@ -2,10 +2,12 @@ @@ -2,10 +2,12 @@
@using System.Reflection
@using ErsatzTV.Extensions
@using ErsatzTV.Application.Search
@using ErsatzTV.Core.Interfaces.Search
@implements IDisposable
@inject NavigationManager NavigationManager
@inject IMediator Mediator
@inject SystemStartup SystemStartup
@inject ISearchTargets SearchTargets;
<MudThemeProvider Theme="_ersatzTvTheme"/>
<MudDialogProvider DisableBackdropClick="true"/>
@ -158,12 +160,16 @@ @@ -158,12 +160,16 @@
{
SystemStartup.OnDatabaseReady += OnStartupProgress;
SystemStartup.OnSearchIndexReady += OnStartupProgress;
SearchTargets.OnSearchTargetsChanged += OnSearchTargetsChanged;
}
public void Dispose()
{
SystemStartup.OnDatabaseReady -= OnStartupProgress;
SystemStartup.OnSearchIndexReady -= OnStartupProgress;
SearchTargets.OnSearchTargetsChanged -= OnSearchTargetsChanged;
_cts.Cancel();
_cts.Dispose();
@ -231,6 +237,11 @@ @@ -231,6 +237,11 @@
}
}
protected async void OnSearchTargetsChanged(object sender, EventArgs e)
{
_searchTargets = await Mediator.Send(new QuerySearchTargets(), _cts.Token);
}
private void PerformSearch()
{
NavigationManager.NavigateTo(_query.GetRelativeSearchQuery(), true);

1
ErsatzTV/Startup.cs

@ -544,6 +544,7 @@ public class Startup @@ -544,6 +544,7 @@ public class Startup
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
services.AddSingleton<ITraktApiClient, TraktApiClient>();
services.AddSingleton<IEntityLocker, EntityLocker>();
services.AddSingleton<ISearchTargets, SearchTargets>();
if (SearchHelper.IsElasticSearchEnabled)
{

Loading…
Cancel
Save