Browse Source

playout rework to maintain collection progress (#720)

* initial work on maintaining playout state

* debugging wip

* fix refresh playout logic

* fix failing test

* more fixes

* update changelog

* comment out some debug logs

* comment out more logs
pull/713/head
Jason Dove 4 years ago committed by GitHub
parent
commit
c2eec2fc2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      CHANGELOG.md
  2. 12
      ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuildHandler.cs
  3. 13
      ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollectionHandler.cs
  4. 11
      ErsatzTV.Application/MediaCollections/Commands/AddEpisodeToCollectionHandler.cs
  5. 12
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
  6. 11
      ErsatzTV.Application/MediaCollections/Commands/AddMovieToCollectionHandler.cs
  7. 11
      ErsatzTV.Application/MediaCollections/Commands/AddMusicVideoToCollectionHandler.cs
  8. 11
      ErsatzTV.Application/MediaCollections/Commands/AddOtherVideoToCollectionHandler.cs
  9. 11
      ErsatzTV.Application/MediaCollections/Commands/AddSeasonToCollectionHandler.cs
  10. 11
      ErsatzTV.Application/MediaCollections/Commands/AddShowToCollectionHandler.cs
  11. 7
      ErsatzTV.Application/MediaCollections/Commands/AddSongToCollectionHandler.cs
  12. 12
      ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs
  13. 12
      ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionCustomOrderHandler.cs
  14. 11
      ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionHandler.cs
  15. 59
      ErsatzTV.Application/MediaCollections/Commands/UpdateMultiCollectionHandler.cs
  16. 11
      ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs
  17. 3
      ErsatzTV.Application/Playouts/Commands/BuildPlayout.cs
  18. 17
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  19. 8
      ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs
  20. 9
      ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs
  21. 9
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs
  22. 12
      ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs
  23. 27
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  24. 3139
      ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs
  25. 3
      ErsatzTV.Core/Domain/PlayoutItem.cs
  26. 16
      ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs
  27. 1
      ErsatzTV.Core/ErsatzTV.Core.csproj
  28. 24
      ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs
  29. 1
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs
  30. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IHlsSessionWorker.cs
  31. 11
      ErsatzTV.Core/Interfaces/Scheduling/IPlayoutBuilder.cs
  32. 13
      ErsatzTV.Core/Scheduling/PlayoutBuildMode.cs
  33. 328
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  34. 8
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs
  35. 8
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs
  36. 8
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs
  37. 12
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs
  38. 8
      ErsatzTV.Core/Scheduling/PlayoutParameters.cs
  39. 3
      ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs
  40. 3864
      ErsatzTV.Infrastructure/Migrations/20220209084802_Add_PlayoutProgramScheduleAnchor_AnchorDate.Designer.cs
  41. 26
      ErsatzTV.Infrastructure/Migrations/20220209084802_Add_PlayoutProgramScheduleAnchor_AnchorDate.cs
  42. 3
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  43. 36
      ErsatzTV/Pages/Playouts.razor
  44. 2
      ErsatzTV/Program.cs
  45. 20
      ErsatzTV/Services/SchedulerService.cs
  46. 20
      ErsatzTV/Shared/SchedulePlayoutReset.razor

9
CHANGELOG.md

@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- Fix `HLS Segmenter` bug where it would drift off of the schedule if a playout was changed while the segmenter was running
### Added ### Added
- Add `Preferred Subtitle Language` and `Subtitle Mode` to channel settings - Add `Preferred Subtitle Language` and `Subtitle Mode` to channel settings
@ -15,6 +17,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed ### Changed
- Remove legacy transcoder logic option; all channels will use the new transcoder logic - Remove legacy transcoder logic option; all channels will use the new transcoder logic
- Renamed channel setting `Preferred Language` to `Preferred Audio Language` - Renamed channel setting `Preferred Language` to `Preferred Audio Language`
- Reworked playout build logic to maintain collection progress in some scenarios. There are now three build modes:
- `Continue` - add new items to the end of an existing playout
- This mode is used when playouts are automatically extended in the background
- `Refresh` - this mode will try to maintain collection progress while rebuilding the entire playout
- This mode is used when a schedule is updated, or when collection modifications trigger a playout rebuild
- `Reset` - this mode will rebuild the entire playout and will NOT maintain progress
- This mode is only used when the `Reset Playout` button is clicked on the Playouts page
## [0.4.5-alpha] - 2022-03-29 ## [0.4.5-alpha] - 2022-03-29
### Fixed ### Fixed

12
ErsatzTV.Application/Configuration/Commands/UpdatePlayoutDaysToBuildHandler.cs

@ -3,13 +3,13 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Configuration; namespace ErsatzTV.Application.Configuration;
public class public class UpdatePlayoutDaysToBuildHandler : IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
UpdatePlayoutDaysToBuildHandler : MediatR.IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
@ -37,16 +37,16 @@ public class
private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild) private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild)
{ {
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild); await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild);
// build all playouts to proper number of days // continue all playouts to proper number of days
List<Playout> playouts = await dbContext.Playouts List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Channel) .Include(p => p.Channel)
.ToListAsync(); .ToListAsync();
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id)) foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
{ {
await _workerChannel.WriteAsync(new BuildPlayout(playoutId)); await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue));
} }
return Unit.Default; return Unit.Default;
} }

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

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -10,7 +11,7 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public class AddArtistToCollectionHandler : public class AddArtistToCollectionHandler :
MediatR.IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>> IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
@ -30,9 +31,9 @@ public class AddArtistToCollectionHandler :
AddArtistToCollection request, AddArtistToCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddArtistRequest(dbContext, parameters)); return await validation.Apply(parameters => ApplyAddArtistRequest(dbContext, parameters));
} }
private async Task<Unit> ApplyAddArtistRequest(TvContext dbContext, Parameters parameters) private async Task<Unit> ApplyAddArtistRequest(TvContext dbContext, Parameters parameters)
@ -40,11 +41,11 @@ public class AddArtistToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Artist); parameters.Collection.MediaItems.Add(parameters.Artist);
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

11
ErsatzTV.Application/MediaCollections/Commands/AddEpisodeToCollectionHandler.cs

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -30,9 +31,9 @@ public class AddEpisodeToCollectionHandler :
AddEpisodeToCollection request, AddEpisodeToCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddTelevisionEpisodeRequest(dbContext, parameters)); return await validation.Apply(parameters => ApplyAddTelevisionEpisodeRequest(dbContext, parameters));
} }
private async Task<Unit> ApplyAddTelevisionEpisodeRequest(TvContext dbContext, Parameters parameters) private async Task<Unit> ApplyAddTelevisionEpisodeRequest(TvContext dbContext, Parameters parameters)
@ -40,11 +41,11 @@ public class AddEpisodeToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Episode); parameters.Collection.MediaItems.Add(parameters.Episode);
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

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

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -36,10 +37,9 @@ public class AddItemsToCollectionHandler :
AddItemsToCollection request, AddItemsToCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request); Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyAddItemsRequest(dbContext, c, request)); return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request));
} }
private async Task<Unit> ApplyAddItemsRequest(TvContext dbContext, Collection collection, AddItemsToCollection request) private async Task<Unit> ApplyAddItemsRequest(TvContext dbContext, Collection collection, AddItemsToCollection request)
@ -63,11 +63,11 @@ public class AddItemsToCollectionHandler :
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId)) .PlayoutIdsUsingCollection(request.CollectionId))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

11
ErsatzTV.Application/MediaCollections/Commands/AddMovieToCollectionHandler.cs

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -30,9 +31,9 @@ public class AddMovieToCollectionHandler :
AddMovieToCollection request, AddMovieToCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddMovieRequest(dbContext, parameters)); return await validation.Apply(parameters => ApplyAddMovieRequest(dbContext, parameters));
} }
private async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters) private async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters)
@ -40,11 +41,11 @@ public class AddMovieToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Movie); parameters.Collection.MediaItems.Add(parameters.Movie);
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

11
ErsatzTV.Application/MediaCollections/Commands/AddMusicVideoToCollectionHandler.cs

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -30,9 +31,9 @@ public class AddMusicVideoToCollectionHandler :
AddMusicVideoToCollection request, AddMusicVideoToCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddMusicVideoRequest(dbContext, parameters)); return await validation.Apply(parameters => ApplyAddMusicVideoRequest(dbContext, parameters));
} }
private async Task<Unit> ApplyAddMusicVideoRequest(TvContext dbContext, Parameters parameters) private async Task<Unit> ApplyAddMusicVideoRequest(TvContext dbContext, Parameters parameters)
@ -40,11 +41,11 @@ public class AddMusicVideoToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.MusicVideo); parameters.Collection.MediaItems.Add(parameters.MusicVideo);
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

11
ErsatzTV.Application/MediaCollections/Commands/AddOtherVideoToCollectionHandler.cs

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -30,9 +31,9 @@ public class AddOtherVideoToCollectionHandler :
AddOtherVideoToCollection request, AddOtherVideoToCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddOtherVideoRequest(dbContext, parameters)); return await validation.Apply(parameters => ApplyAddOtherVideoRequest(dbContext, parameters));
} }
private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters) private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters)
@ -40,11 +41,11 @@ public class AddOtherVideoToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.OtherVideo); parameters.Collection.MediaItems.Add(parameters.OtherVideo);
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

11
ErsatzTV.Application/MediaCollections/Commands/AddSeasonToCollectionHandler.cs

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -30,9 +31,9 @@ public class AddSeasonToCollectionHandler :
AddSeasonToCollection request, AddSeasonToCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddSeasonRequest(dbContext, parameters)); return await validation.Apply(parameters => ApplyAddSeasonRequest(dbContext, parameters));
} }
private async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters) private async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters)
@ -40,11 +41,11 @@ public class AddSeasonToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Season); parameters.Collection.MediaItems.Add(parameters.Season);
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

11
ErsatzTV.Application/MediaCollections/Commands/AddShowToCollectionHandler.cs

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -30,9 +31,9 @@ public class AddShowToCollectionHandler :
AddShowToCollection request, AddShowToCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, parameters => ApplyAddShowRequest(dbContext, parameters)); return await validation.Apply(parameters => ApplyAddShowRequest(dbContext, parameters));
} }
private async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters) private async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters)
@ -40,11 +41,11 @@ public class AddShowToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Show); parameters.Collection.MediaItems.Add(parameters.Show);
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

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

@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -40,11 +41,11 @@ public class AddSongToCollectionHandler :
parameters.Collection.MediaItems.Add(parameters.Song); parameters.Collection.MediaItems.Add(parameters.Song);
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id)) .PlayoutIdsUsingCollection(parameters.Collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

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

@ -3,14 +3,14 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public class RemoveItemsFromCollectionHandler : public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromCollection, Either<BaseError, Unit>>
MediatR.IRequestHandler<RemoveItemsFromCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
@ -30,9 +30,9 @@ public class RemoveItemsFromCollectionHandler :
RemoveItemsFromCollection request, RemoveItemsFromCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request); Validation<BaseError, Collection> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, c => ApplyRemoveItemsRequest(dbContext, request, c)); return await validation.Apply(c => ApplyRemoveItemsRequest(dbContext, request, c));
} }
private async Task<Unit> ApplyRemoveItemsRequest( private async Task<Unit> ApplyRemoveItemsRequest(
@ -48,10 +48,10 @@ public class RemoveItemsFromCollectionHandler :
if (itemsToRemove.Any() && await dbContext.SaveChangesAsync() > 0) if (itemsToRemove.Any() && await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id)) foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

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

@ -3,14 +3,14 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public class UpdateCollectionCustomOrderHandler : public class UpdateCollectionCustomOrderHandler : IRequestHandler<UpdateCollectionCustomOrder, Either<BaseError, Unit>>
MediatR.IRequestHandler<UpdateCollectionCustomOrder, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
@ -30,9 +30,9 @@ public class UpdateCollectionCustomOrderHandler :
UpdateCollectionCustomOrder request, UpdateCollectionCustomOrder request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request); 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( private async Task<Unit> ApplyUpdateRequest(
@ -53,11 +53,11 @@ public class UpdateCollectionCustomOrderHandler :
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId)) .PlayoutIdsUsingCollection(request.CollectionId))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

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

@ -3,13 +3,14 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public class UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection, Either<BaseError, Unit>> public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
@ -29,9 +30,9 @@ public class UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection,
UpdateCollection request, UpdateCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request); 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) private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, Collection c, UpdateCollection request)
@ -44,11 +45,11 @@ public class UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection,
if (await dbContext.SaveChangesAsync() > 0 && request.UseCustomPlaybackOrder.IsSome) if (await dbContext.SaveChangesAsync() > 0 && request.UseCustomPlaybackOrder.IsSome)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection( foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
request.CollectionId)) request.CollectionId))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

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

@ -3,13 +3,14 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiCollection, Either<BaseError, Unit>> public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
@ -29,15 +30,15 @@ public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiC
UpdateMultiCollection request, UpdateMultiCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request); Validation<BaseError, MultiCollection> 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, MultiCollection c, UpdateMultiCollection request) private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, MultiCollection c, UpdateMultiCollection request)
{ {
c.Name = request.Name; c.Name = request.Name;
// save name first so playouts don't get rebuilt for a name change // save name first so playouts don't get rebuilt for a name change
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@ -45,22 +46,23 @@ public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiC
.Filter(i => i.CollectionId.HasValue) .Filter(i => i.CollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException // ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId.Value)) .Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId.Value))
.Map(i => new MultiCollectionItem .Map(
{ i => new MultiCollectionItem
// ReSharper disable once PossibleInvalidOperationException {
CollectionId = i.CollectionId.Value, // ReSharper disable once PossibleInvalidOperationException
MultiCollectionId = c.Id, CollectionId = i.CollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup, MultiCollectionId = c.Id,
PlaybackOrder = i.PlaybackOrder ScheduleAsGroup = i.ScheduleAsGroup,
}) PlaybackOrder = i.PlaybackOrder
})
.ToList(); .ToList();
var toRemove = c.MultiCollectionItems var toRemove = c.MultiCollectionItems
.Filter(i => request.Items.All(i2 => i2.CollectionId != i.CollectionId)) .Filter(i => request.Items.All(i2 => i2.CollectionId != i.CollectionId))
.ToList(); .ToList();
// remove items that are no longer present // remove items that are no longer present
c.MultiCollectionItems.RemoveAll(toRemove.Contains); c.MultiCollectionItems.RemoveAll(toRemove.Contains);
// update existing items // update existing items
foreach (MultiCollectionItem item in c.MultiCollectionItems) foreach (MultiCollectionItem item in c.MultiCollectionItems)
{ {
@ -79,22 +81,23 @@ public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiC
.Filter(i => i.SmartCollectionId.HasValue) .Filter(i => i.SmartCollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException // ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionSmartItems.All(i2 => i2.SmartCollectionId != i.SmartCollectionId.Value)) .Filter(i => c.MultiCollectionSmartItems.All(i2 => i2.SmartCollectionId != i.SmartCollectionId.Value))
.Map(i => new MultiCollectionSmartItem .Map(
{ i => new MultiCollectionSmartItem
// ReSharper disable once PossibleInvalidOperationException {
SmartCollectionId = i.SmartCollectionId.Value, // ReSharper disable once PossibleInvalidOperationException
MultiCollectionId = c.Id, SmartCollectionId = i.SmartCollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup, MultiCollectionId = c.Id,
PlaybackOrder = i.PlaybackOrder ScheduleAsGroup = i.ScheduleAsGroup,
}) PlaybackOrder = i.PlaybackOrder
})
.ToList(); .ToList();
var toRemoveSmart = c.MultiCollectionSmartItems var toRemoveSmart = c.MultiCollectionSmartItems
.Filter(i => request.Items.All(i2 => i2.SmartCollectionId != i.SmartCollectionId)) .Filter(i => request.Items.All(i2 => i2.SmartCollectionId != i.SmartCollectionId))
.ToList(); .ToList();
// remove items that are no longer present // remove items that are no longer present
c.MultiCollectionSmartItems.RemoveAll(toRemoveSmart.Contains); c.MultiCollectionSmartItems.RemoveAll(toRemoveSmart.Contains);
// update existing items // update existing items
foreach (MultiCollectionSmartItem item in c.MultiCollectionSmartItems) foreach (MultiCollectionSmartItem item in c.MultiCollectionSmartItems)
{ {
@ -112,11 +115,11 @@ public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiC
// rebuild playouts // rebuild playouts
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection( foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection(
request.MultiCollectionId)) request.MultiCollectionId))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }
@ -138,7 +141,9 @@ public class UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiC
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId) .SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist.")); .Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName(TvContext dbContext, UpdateMultiCollection updateMultiCollection) private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
UpdateMultiCollection updateMultiCollection)
{ {
List<string> allNames = await dbContext.MultiCollections List<string> allNames = await dbContext.MultiCollections
.Filter(mc => mc.Id != updateMultiCollection.MultiCollectionId) .Filter(mc => mc.Id != updateMultiCollection.MultiCollectionId)

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

@ -3,13 +3,14 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>> public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
@ -29,9 +30,9 @@ public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartC
UpdateSmartCollection request, UpdateSmartCollection request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request); 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) private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, SmartCollection c, UpdateSmartCollection request)
@ -41,10 +42,10 @@ public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartC
// rebuild playouts // rebuild playouts
if (await dbContext.SaveChangesAsync() > 0) if (await dbContext.SaveChangesAsync() > 0)
{ {
// rebuild all playouts that use this smart collection // refresh all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id)) foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

3
ErsatzTV.Application/Playouts/Commands/BuildPlayout.cs

@ -1,6 +1,7 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
public record BuildPlayout(int PlayoutId, bool Rebuild = false) : MediatR.IRequest<Either<BaseError, Unit>>, public record BuildPlayout(int PlayoutId, PlayoutBuildMode Mode) : IRequest<Either<BaseError, Unit>>,
IBackgroundServiceRequest; IBackgroundServiceRequest;

17
ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs

@ -1,6 +1,7 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -13,12 +14,18 @@ public class BuildPlayoutHandler : MediatR.IRequestHandler<BuildPlayout, Either<
private readonly IClient _client; private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IPlayoutBuilder _playoutBuilder; private readonly IPlayoutBuilder _playoutBuilder;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
public BuildPlayoutHandler(IClient client, IDbContextFactory<TvContext> dbContextFactory, IPlayoutBuilder playoutBuilder) public BuildPlayoutHandler(
IClient client,
IDbContextFactory<TvContext> dbContextFactory,
IPlayoutBuilder playoutBuilder,
IFFmpegSegmenterService ffmpegSegmenterService)
{ {
_client = client; _client = client;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_playoutBuilder = playoutBuilder; _playoutBuilder = playoutBuilder;
_ffmpegSegmenterService = ffmpegSegmenterService;
} }
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken) public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
@ -32,8 +39,11 @@ public class BuildPlayoutHandler : MediatR.IRequestHandler<BuildPlayout, Either<
{ {
try try
{ {
await _playoutBuilder.BuildPlayoutItems(playout, request.Rebuild); await _playoutBuilder.Build(playout, request.Mode);
await dbContext.SaveChangesAsync(); if (await dbContext.SaveChangesAsync() > 0)
{
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -42,7 +52,6 @@ public class BuildPlayoutHandler : MediatR.IRequestHandler<BuildPlayout, Either<
return Unit.Default; return Unit.Default;
} }
private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, BuildPlayout request) => private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, BuildPlayout request) =>
PlayoutMustExist(dbContext, request); PlayoutMustExist(dbContext, request);

8
ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs

@ -1,6 +1,7 @@
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -25,17 +26,16 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
CreatePlayout request, CreatePlayout request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request); Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, playout => PersistPlayout(dbContext, playout)); return await validation.Apply(playout => PersistPlayout(dbContext, playout));
} }
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout) private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
{ {
await dbContext.Playouts.AddAsync(playout); await dbContext.Playouts.AddAsync(playout);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await _channel.WriteAsync(new BuildPlayout(playout.Id)); await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
return new CreatePlayoutResponse(playout.Id); return new CreatePlayoutResponse(playout.Id);
} }

9
ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs

@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.ProgramSchedules.Mapper; using static ErsatzTV.Application.ProgramSchedules.Mapper;
@ -26,9 +27,9 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
AddProgramScheduleItem request, AddProgramScheduleItem request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request); Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => PersistItem(dbContext, request, ps)); return await validation.Apply(ps => PersistItem(dbContext, request, ps));
} }
private async Task<ProgramScheduleItemViewModel> PersistItem( private async Task<ProgramScheduleItemViewModel> PersistItem(
@ -43,10 +44,10 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// rebuild any playouts that use this schedule // refresh any playouts that use this schedule
foreach (Playout playout in programSchedule.Playouts) foreach (Playout playout in programSchedule.Playouts)
{ {
await _channel.WriteAsync(new BuildPlayout(playout.Id, true)); await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
} }
return ProjectToViewModel(item); return ProjectToViewModel(item);

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

@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.ProgramSchedules.Mapper; using static ErsatzTV.Application.ProgramSchedules.Mapper;
@ -26,9 +27,9 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
ReplaceProgramScheduleItems request, ReplaceProgramScheduleItems request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request); Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => PersistItems(dbContext, request, ps)); return await validation.Apply(ps => PersistItems(dbContext, request, ps));
} }
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems( private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(
@ -41,10 +42,10 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// rebuild any playouts that use this schedule // refresh any playouts that use this schedule
foreach (Playout playout in programSchedule.Playouts) foreach (Playout playout in programSchedule.Playouts)
{ {
await _channel.WriteAsync(new BuildPlayout(playout.Id, true)); await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
} }
return programSchedule.Items.Map(ProjectToViewModel); return programSchedule.Items.Map(ProjectToViewModel);

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

@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -27,9 +28,8 @@ public class UpdateProgramScheduleHandler :
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request); Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => ApplyUpdateRequest(dbContext, ps, request)); return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
} }
private async Task<UpdateProgramScheduleResult> ApplyUpdateRequest( private async Task<UpdateProgramScheduleResult> ApplyUpdateRequest(
@ -37,8 +37,8 @@ public class UpdateProgramScheduleHandler :
ProgramSchedule programSchedule, ProgramSchedule programSchedule,
UpdateProgramSchedule request) UpdateProgramSchedule request)
{ {
// we need to rebuild playouts if the playback order or keep multi-episodes has been modified // we need to refresh playouts if the playback order or keep multi-episodes has been modified
bool needToRebuildPlayout = bool needToRefreshPlayout =
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether || programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows || programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems; programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems;
@ -51,7 +51,7 @@ public class UpdateProgramScheduleHandler :
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
if (needToRebuildPlayout) if (needToRefreshPlayout)
{ {
List<int> playoutIds = await dbContext.Playouts List<int> playoutIds = await dbContext.Playouts
.Filter(p => p.ProgramScheduleId == programSchedule.Id) .Filter(p => p.ProgramScheduleId == programSchedule.Id)
@ -60,7 +60,7 @@ public class UpdateProgramScheduleHandler :
foreach (int playoutId in playoutIds) foreach (int playoutId in playoutIds)
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
} }
} }

27
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -30,6 +30,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private DateTimeOffset _playlistStart; private DateTimeOffset _playlistStart;
private Option<int> _targetFramerate; private Option<int> _targetFramerate;
private string _channelNumber; private string _channelNumber;
private bool _firstProcess;
public HlsSessionWorker( public HlsSessionWorker(
IHlsPlaylistFilter hlsPlaylistFilter, IHlsPlaylistFilter hlsPlaylistFilter,
@ -70,6 +71,8 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
} }
public void PlayoutUpdated() => _firstProcess = true;
public async Task Run(string channelNumber, TimeSpan idleTimeout, CancellationToken incomingCancellationToken) public async Task Run(string channelNumber, TimeSpan idleTimeout, CancellationToken incomingCancellationToken)
{ {
var cts = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken); var cts = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken);
@ -105,8 +108,10 @@ public class HlsSessionWorker : IHlsSessionWorker
_transcodedUntil = DateTimeOffset.Now; _transcodedUntil = DateTimeOffset.Now;
_playlistStart = _transcodedUntil; _playlistStart = _transcodedUntil;
_firstProcess = true;
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(); bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
if (!await Transcode(true, !initialWorkAhead, cancellationToken)) if (!await Transcode(!initialWorkAhead, cancellationToken))
{ {
return; return;
} }
@ -127,7 +132,7 @@ public class HlsSessionWorker : IHlsSessionWorker
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30); bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
bool subsequentWorkAhead = bool subsequentWorkAhead =
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(); !realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
if (!await Transcode(false, !subsequentWorkAhead, cancellationToken)) if (!await Transcode(!subsequentWorkAhead, cancellationToken))
{ {
return; return;
} }
@ -149,7 +154,6 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
private async Task<bool> Transcode( private async Task<bool> Transcode(
bool firstProcess,
bool realtime, bool realtime,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@ -177,8 +181,8 @@ public class HlsSessionWorker : IHlsSessionWorker
var request = new GetPlayoutItemProcessByChannelNumber( var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber, _channelNumber,
"segmenter", "segmenter",
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1), _firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1),
!firstProcess, !_firstProcess,
realtime, realtime,
ptsOffset, ptsOffset,
_targetFramerate); _targetFramerate);
@ -220,6 +224,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
_logger.LogInformation("HLS process has completed for channel {Channel}", _channelNumber); _logger.LogInformation("HLS process has completed for channel {Channel}", _channelNumber);
_transcodedUntil = processModel.Until; _transcodedUntil = processModel.Until;
_firstProcess = false;
return true; return true;
} }
else else
@ -237,7 +242,7 @@ public class HlsSessionWorker : IHlsSessionWorker
_channelNumber, _channelNumber,
commandResult.ExitCode, commandResult.ExitCode,
commandResult.StandardError); commandResult.StandardError);
Either<BaseError, PlayoutItemProcessModel> maybeOfflineProcess = await mediator.Send( Either<BaseError, PlayoutItemProcessModel> maybeOfflineProcess = await mediator.Send(
new GetErrorProcess( new GetErrorProcess(
_channelNumber, _channelNumber,
@ -252,7 +257,7 @@ public class HlsSessionWorker : IHlsSessionWorker
foreach (PlayoutItemProcessModel errorProcessModel in maybeOfflineProcess.RightAsEnumerable()) foreach (PlayoutItemProcessModel errorProcessModel in maybeOfflineProcess.RightAsEnumerable())
{ {
Process errorProcess = errorProcessModel.Process; Process errorProcess = errorProcessModel.Process;
_logger.LogInformation( _logger.LogInformation(
"ffmpeg hls error arguments {FFmpegArguments}", "ffmpeg hls error arguments {FFmpegArguments}",
string.Join(" ", errorProcess.StartInfo.ArgumentList)); string.Join(" ", errorProcess.StartInfo.ArgumentList));
@ -261,8 +266,12 @@ public class HlsSessionWorker : IHlsSessionWorker
.WithArguments(errorProcess.StartInfo.ArgumentList) .WithArguments(errorProcess.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None) .WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken); .ExecuteBufferedAsync(cancellationToken);
return commandResult.ExitCode == 0; if (commandResult.ExitCode == 0)
{
_firstProcess = false;
return true;
}
} }
return false; return false;

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

File diff suppressed because it is too large Load Diff

3
ErsatzTV.Core/Domain/PlayoutItem.cs

@ -3,7 +3,7 @@ using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Domain;
[DebuggerDisplay("{MediaItemId} - {Start} - {Finish}")] [DebuggerDisplay("{MediaItemId} - {StartOffset} - {FinishOffset}")]
public class PlayoutItem public class PlayoutItem
{ {
public int Id { get; set; } public int Id { get; set; }
@ -23,6 +23,7 @@ public class PlayoutItem
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime(); public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime(); public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset? GuideFinishOffset => GuideFinish.HasValue public DateTimeOffset? GuideFinishOffset => GuideFinish.HasValue
? new DateTimeOffset(GuideFinish.Value, TimeSpan.Zero).ToLocalTime() ? new DateTimeOffset(GuideFinish.Value, TimeSpan.Zero).ToLocalTime()
: null; : null;

16
ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs

@ -1,12 +1,26 @@
namespace ErsatzTV.Core.Domain; using Destructurama.Attributed;
namespace ErsatzTV.Core.Domain;
public class PlayoutProgramScheduleAnchor public class PlayoutProgramScheduleAnchor
{ {
public int Id { get; set; } public int Id { get; set; }
public int PlayoutId { get; set; } public int PlayoutId { get; set; }
[NotLogged]
public Playout Playout { get; set; } public Playout Playout { get; set; }
public int ProgramScheduleId { get; set; } public int ProgramScheduleId { get; set; }
[NotLogged]
public ProgramSchedule ProgramSchedule { get; set; } public ProgramSchedule ProgramSchedule { get; set; }
public DateTime? AnchorDate { get; set; }
public DateTimeOffset? AnchorDateOffset => AnchorDate.HasValue
? new DateTimeOffset(AnchorDate.Value, TimeSpan.Zero).ToLocalTime()
: null;
public ProgramScheduleItemCollectionType CollectionType { get; set; } public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? CollectionId { get; set; } public int? CollectionId { get; set; }
public Collection Collection { get; set; } public Collection Collection { get; set; }

1
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -8,6 +8,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.0" /> <PackageReference Include="Bugsnag" Version="3.0.0" />
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
<PackageReference Include="Flurl" Version="3.0.4" /> <PackageReference Include="Flurl" Version="3.0.4" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" /> <PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="MediatR" Version="10.0.1" /> <PackageReference Include="MediatR" Version="10.0.1" />

24
ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs

@ -1,12 +1,17 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.FFmpeg;
public class FFmpegSegmenterService : IFFmpegSegmenterService public class FFmpegSegmenterService : IFFmpegSegmenterService
{ {
public FFmpegSegmenterService() private readonly ILogger<FFmpegSegmenterService> _logger;
public FFmpegSegmenterService(ILogger<FFmpegSegmenterService> logger)
{ {
_logger = logger;
SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>(); SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>();
} }
@ -19,4 +24,19 @@ public class FFmpegSegmenterService : IFFmpegSegmenterService
worker?.Touch(); worker?.Touch();
} }
} }
}
public void PlayoutUpdated(string channelNumber)
{
if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{
if (worker != null)
{
_logger.LogInformation(
"Playout has been updated for channel {ChannelNumber}, HLS segmenter will skip ahead to catch up",
channelNumber);
worker.PlayoutUpdated();
}
}
}
}

1
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs

@ -7,4 +7,5 @@ public interface IFFmpegSegmenterService
ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; } ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
void TouchChannel(string channelNumber); void TouchChannel(string channelNumber);
void PlayoutUpdated(string channelNumber);
} }

2
ErsatzTV.Core/Interfaces/FFmpeg/IHlsSessionWorker.cs

@ -4,7 +4,7 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg;
public interface IHlsSessionWorker public interface IHlsSessionWorker
{ {
DateTimeOffset PlaylistStart { get; }
void Touch(); void Touch();
Task<Option<TrimPlaylistResult>> TrimPlaylist(DateTimeOffset filterBefore, CancellationToken cancellationToken); Task<Option<TrimPlaylistResult>> TrimPlaylist(DateTimeOffset filterBefore, CancellationToken cancellationToken);
void PlayoutUpdated();
} }

11
ErsatzTV.Core/Interfaces/Scheduling/IPlayoutBuilder.cs

@ -1,14 +1,9 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Interfaces.Scheduling; namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IPlayoutBuilder public interface IPlayoutBuilder
{ {
Task<Playout> BuildPlayoutItems(Playout playout, bool rebuild = false); Task<Playout> Build(Playout playout, PlayoutBuildMode mode);
Task<Playout> BuildPlayoutItems(
Playout playout,
DateTimeOffset playoutStart,
DateTimeOffset playoutFinish,
bool rebuild = false);
} }

13
ErsatzTV.Core/Scheduling/PlayoutBuildMode.cs

@ -0,0 +1,13 @@
namespace ErsatzTV.Core.Scheduling;
public enum PlayoutBuildMode
{
// this continues building playout into the future
Continue = 1,
// this rebuilds a playout but will maintain collection progress
Refresh = 2,
// this rebuilds a playout and clears all state
Reset = 3
}

328
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -32,18 +32,190 @@ public class PlayoutBuilder : IPlayoutBuilder
_logger = logger; _logger = logger;
} }
public async Task<Playout> BuildPlayoutItems(Playout playout, bool rebuild = false) public async Task<Playout> Build(Playout playout, PlayoutBuildMode mode)
{ {
DateTimeOffset now = DateTimeOffset.Now; foreach (PlayoutParameters parameters in await Validate(playout))
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild); {
return await BuildPlayoutItems(playout, now, now.AddDays(await daysToBuild.IfNoneAsync(2)), rebuild); // for testing purposes
// if (mode == PlayoutBuildMode.Reset)
// {
// return await Build(playout, mode, parameters with { Start = parameters.Start.AddDays(-2) });
// }
return await Build(playout, mode, parameters);
}
return playout;
} }
public async Task<Playout> BuildPlayoutItems( private Task<Playout> Build(Playout playout, PlayoutBuildMode mode, PlayoutParameters parameters) =>
mode switch
{
PlayoutBuildMode.Refresh => RefreshPlayout(playout, parameters),
PlayoutBuildMode.Reset => ResetPlayout(playout, parameters),
_ => ContinuePlayout(playout, parameters)
};
internal async Task<Playout> Build(
Playout playout, Playout playout,
DateTimeOffset playoutStart, PlayoutBuildMode mode,
DateTimeOffset playoutFinish, DateTimeOffset start,
bool rebuild = false) DateTimeOffset finish)
{
foreach (PlayoutParameters parameters in await Validate(playout))
{
return await Build(playout, mode, parameters with { Start = start, Finish = finish });
}
return playout;
}
private async Task<Playout> RefreshPlayout(Playout playout, PlayoutParameters parameters)
{
_logger.LogDebug(
"Refreshing playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}",
playout.Id,
playout.Channel.Number,
playout.Channel.Name);
playout.Items.Clear();
playout.Anchor = null;
// foreach (PlayoutProgramScheduleAnchor anchor in playout.ProgramScheduleAnchors)
// {
// anchor.Playout = null;
// anchor.Collection = null;
// anchor.ProgramSchedule = null;
// }
// _logger.LogDebug("All anchors: {@Anchors}", playout.ProgramScheduleAnchors);
// remove null anchor date ("continue" anchors)
playout.ProgramScheduleAnchors.RemoveAll(a => a.AnchorDate is null);
// _logger.LogDebug("Checkpoint anchors: {@Anchors}", playout.ProgramScheduleAnchors);
// remove old checkpoints
playout.ProgramScheduleAnchors.RemoveAll(
a => a.AnchorDateOffset.IfNone(SystemTime.MaxValueUtc) < parameters.Start.Date);
// remove new checkpoints
playout.ProgramScheduleAnchors.RemoveAll(
a => a.AnchorDateOffset.IfNone(SystemTime.MinValueUtc).Date > parameters.Start.Date);
// _logger.LogDebug("Remaining anchors: {@Anchors}", playout.ProgramScheduleAnchors);
var allAnchors = playout.ProgramScheduleAnchors.ToList();
var collectionIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.CollectionId)).Somes().ToHashSet();
var multiCollectionIds =
playout.ProgramScheduleAnchors.Map(a => Optional(a.MultiCollectionId)).Somes().ToHashSet();
var smartCollectionIds =
playout.ProgramScheduleAnchors.Map(a => Optional(a.SmartCollectionId)).Somes().ToHashSet();
var mediaItemIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.MediaItemId)).Somes().ToHashSet();
playout.ProgramScheduleAnchors.Clear();
foreach (int collectionId in collectionIds)
{
PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.CollectionId == collectionId)
.MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks);
playout.ProgramScheduleAnchors.Add(minAnchor);
}
foreach (int multiCollectionId in multiCollectionIds)
{
PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.MultiCollectionId == multiCollectionId)
.MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks);
playout.ProgramScheduleAnchors.Add(minAnchor);
}
foreach (int smartCollectionId in smartCollectionIds)
{
PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.SmartCollectionId == smartCollectionId)
.MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks);
playout.ProgramScheduleAnchors.Add(minAnchor);
}
foreach (int mediaItemId in mediaItemIds)
{
PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.MediaItemId == mediaItemId)
.MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks);
playout.ProgramScheduleAnchors.Add(minAnchor);
}
// _logger.LogDebug("Oldest anchors for each collection: {@Anchors}", playout.ProgramScheduleAnchors);
// convert checkpoints to non-checkpoints
// foreach (PlayoutProgramScheduleAnchor anchor in playout.ProgramScheduleAnchors)
// {
// anchor.AnchorDate = null;
// }
// _logger.LogDebug("Final anchors: {@Anchors}", playout.ProgramScheduleAnchors);
Option<DateTime> maybeAnchorDate = playout.ProgramScheduleAnchors
.Map(a => Optional(a.AnchorDate))
.Somes()
.HeadOrNone();
foreach (DateTime anchorDate in maybeAnchorDate)
{
playout.Anchor = new PlayoutAnchor
{
NextStart = anchorDate
};
}
return await BuildPlayoutItems(
playout,
parameters.Start,
parameters.Finish,
parameters.CollectionMediaItems);
}
private async Task<Playout> ResetPlayout(Playout playout, PlayoutParameters parameters)
{
_logger.LogDebug(
"Resetting playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}",
playout.Id,
playout.Channel.Number,
playout.Channel.Name);
playout.Items.Clear();
playout.Anchor = null;
playout.ProgramScheduleAnchors.Clear();
await BuildPlayoutItems(
playout,
parameters.Start,
parameters.Finish,
parameters.CollectionMediaItems);
return playout;
}
private async Task<Playout> ContinuePlayout(Playout playout, PlayoutParameters parameters)
{
_logger.LogDebug(
"Building playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}",
playout.Id,
playout.Channel.Number,
playout.Channel.Name);
await BuildPlayoutItems(
playout,
parameters.Start,
parameters.Finish,
parameters.CollectionMediaItems);
return playout;
}
private async Task<Option<PlayoutParameters>> Validate(Playout playout)
{ {
Map<CollectionKey, List<MediaItem>> collectionMediaItems = await GetCollectionMediaItems(playout); Map<CollectionKey, List<MediaItem>> collectionMediaItems = await GetCollectionMediaItems(playout);
if (!collectionMediaItems.Any()) if (!collectionMediaItems.Any())
@ -53,16 +225,9 @@ public class PlayoutBuilder : IPlayoutBuilder
playout.Channel.Name, playout.Channel.Name,
playout.ProgramSchedule.Name); playout.ProgramSchedule.Name);
return playout; return None;
} }
_logger.LogDebug(
"{Action} playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}",
rebuild ? "Rebuilding" : "Building",
playout.Id,
playout.Channel.Number,
playout.Channel.Name);
Option<CollectionKey> maybeEmptyCollection = await CheckForEmptyCollections(collectionMediaItems); Option<CollectionKey> maybeEmptyCollection = await CheckForEmptyCollections(collectionMediaItems);
foreach (CollectionKey emptyCollection in maybeEmptyCollection) foreach (CollectionKey emptyCollection in maybeEmptyCollection)
{ {
@ -84,19 +249,76 @@ public class PlayoutBuilder : IPlayoutBuilder
emptyCollection); emptyCollection);
} }
return playout; return None;
} }
playout.Items ??= new List<PlayoutItem>(); playout.Items ??= new List<PlayoutItem>();
playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>(); playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>();
if (rebuild) Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
DateTimeOffset now = DateTimeOffset.Now;
return new PlayoutParameters(
now,
now.AddDays(await daysToBuild.IfNoneAsync(2)),
collectionMediaItems);
}
private async Task<Playout> BuildPlayoutItems(
Playout playout,
DateTimeOffset playoutStart,
DateTimeOffset playoutFinish,
Map<CollectionKey, List<MediaItem>> collectionMediaItems)
{
DateTimeOffset trimBefore = playoutStart.AddHours(-4);
DateTimeOffset trimAfter = playoutFinish;
DateTimeOffset start = playoutStart;
DateTimeOffset finish = playoutStart.Date.AddDays(1);
_logger.LogDebug(
"Trim before: {TrimBefore}, Start: {Start}, Finish: {Finish}, PlayoutFinish: {PlayoutFinish}",
trimBefore,
start,
finish,
playoutFinish);
// build each day with "continue" anchors
while (finish < playoutFinish)
{
_logger.LogDebug("Building playout from {Start} to {Finish}", start, finish);
playout = await BuildPlayoutItems(playout, start, finish, collectionMediaItems, true);
start = playout.Anchor.NextStartOffset;
finish = finish.AddDays(1);
}
if (start < playoutFinish)
{ {
playout.Items.Clear(); // build one final time without continue anchors
playout.Anchor = null; _logger.LogDebug("Building final playout from {Start} to {Finish}", start, playoutFinish);
playout.ProgramScheduleAnchors.Clear(); playout = await BuildPlayoutItems(
playout,
start,
playoutFinish,
collectionMediaItems,
false);
} }
// remove any items outside the desired range
playout.Items.RemoveAll(old => old.FinishOffset < trimBefore || old.StartOffset > trimAfter);
return playout;
}
private async Task<Playout> BuildPlayoutItems(
Playout playout,
DateTimeOffset playoutStart,
DateTimeOffset playoutFinish,
Map<CollectionKey, List<MediaItem>> collectionMediaItems,
bool saveAnchorDate)
{
var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList(); var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
CollectionEnumeratorState scheduleItemsEnumeratorState = CollectionEnumeratorState scheduleItemsEnumeratorState =
playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState
@ -122,13 +344,13 @@ public class PlayoutBuilder : IPlayoutBuilder
// start at the previously-decided time // start at the previously-decided time
DateTimeOffset currentTime = startAnchor.NextStartOffset.ToLocalTime(); DateTimeOffset currentTime = startAnchor.NextStartOffset.ToLocalTime();
_logger.LogDebug( // _logger.LogDebug(
"Starting playout {PlayoutId} for channel {ChannelNumber} - {ChannelName} at {StartTime}", // "Starting playout ({PlayoutId}) for channel {ChannelNumber} - {ChannelName} at {StartTime}",
playout.Id, // playout.Id,
playout.Channel.Number, // playout.Channel.Number,
playout.Channel.Name, // playout.Channel.Name,
currentTime); // currentTime);
// removing any items scheduled past the start anchor // removing any items scheduled past the start anchor
// this could happen if the app was closed after scheduling items // this could happen if the app was closed after scheduling items
// but before saving the anchor // but before saving the anchor
@ -204,13 +426,6 @@ public class PlayoutBuilder : IPlayoutBuilder
// once more to get playout anchor // once more to get playout anchor
ProgramScheduleItem anchorScheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Current; ProgramScheduleItem anchorScheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Current;
// build program schedule anchors
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators);
// remove any items outside the desired range
playout.Items.RemoveAll(
old => old.FinishOffset < playoutStart.AddHours(-4) || old.StartOffset > playoutFinish);
if (playout.Items.Any()) if (playout.Items.Any())
{ {
DateTimeOffset maxStartTime = playout.Items.Max(i => i.FinishOffset); DateTimeOffset maxStartTime = playout.Items.Max(i => i.FinishOffset);
@ -223,19 +438,27 @@ public class PlayoutBuilder : IPlayoutBuilder
playout.Anchor = new PlayoutAnchor playout.Anchor = new PlayoutAnchor
{ {
ScheduleItemsEnumeratorState = playoutBuilderState.ScheduleItemsEnumerator.State, ScheduleItemsEnumeratorState = playoutBuilderState.ScheduleItemsEnumerator.State,
NextStart = PlayoutModeSchedulerBase<ProgramScheduleItem>.GetStartTimeAfter(playoutBuilderState, anchorScheduleItem) NextStart = PlayoutModeSchedulerBase<ProgramScheduleItem>
.GetStartTimeAfter(playoutBuilderState, anchorScheduleItem)
.UtcDateTime, .UtcDateTime,
MultipleRemaining = playoutBuilderState.MultipleRemaining.IsSome
? playoutBuilderState.MultipleRemaining.ValueUnsafe()
: null,
DurationFinish = playoutBuilderState.DurationFinish.IsSome
? playoutBuilderState.DurationFinish.ValueUnsafe().UtcDateTime
: null,
InFlood = playoutBuilderState.InFlood, InFlood = playoutBuilderState.InFlood,
InDurationFiller = playoutBuilderState.InDurationFiller, InDurationFiller = playoutBuilderState.InDurationFiller,
NextGuideGroup = playoutBuilderState.NextGuideGroup NextGuideGroup = playoutBuilderState.NextGuideGroup
}; };
foreach (int multipleRemaining in playoutBuilderState.MultipleRemaining)
{
playout.Anchor.MultipleRemaining = multipleRemaining;
}
foreach (DateTimeOffset durationFinish in playoutBuilderState.DurationFinish)
{
playout.Anchor.DurationFinish = durationFinish.UtcDateTime;
}
// build program schedule anchors
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators, saveAnchorDate);
return playout; return playout;
} }
@ -311,7 +534,7 @@ public class PlayoutBuilder : IPlayoutBuilder
return collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key); return collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
} }
private static PlayoutAnchor FindStartAnchor( private static PlayoutAnchor FindStartAnchor(
Playout playout, Playout playout,
DateTimeOffset start, DateTimeOffset start,
@ -341,7 +564,8 @@ public class PlayoutBuilder : IPlayoutBuilder
private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors( private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors(
Playout playout, Playout playout,
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators) Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
bool saveAnchorDate)
{ {
var result = new List<PlayoutProgramScheduleAnchor>(); var result = new List<PlayoutProgramScheduleAnchor>();
@ -350,7 +574,8 @@ public class PlayoutBuilder : IPlayoutBuilder
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault( Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault(
a => a.CollectionType == collectionKey.CollectionType a => a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId && a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId); && a.MediaItemId == collectionKey.MediaItemId
&& a.AnchorDate is null);
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary( var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary(
mcs => mcs.Key, mcs => mcs.Key,
@ -376,9 +601,20 @@ public class PlayoutBuilder : IPlayoutBuilder
EnumeratorState = maybeEnumeratorState[collectionKey] EnumeratorState = maybeEnumeratorState[collectionKey]
}); });
if (saveAnchorDate)
{
scheduleAnchor.AnchorDate = playout.Anchor?.NextStart;
}
result.Add(scheduleAnchor); result.Add(scheduleAnchor);
} }
foreach (PlayoutProgramScheduleAnchor continueAnchor in playout.ProgramScheduleAnchors.Where(
a => a.AnchorDate is not null))
{
result.Add(continueAnchor);
}
return result; return result;
} }
@ -416,7 +652,7 @@ public class PlayoutBuilder : IPlayoutBuilder
state); state);
} }
} }
switch (playbackOrder) switch (playbackOrder)
{ {
case PlaybackOrder.Chronological: case PlaybackOrder.Chronological:

8
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs

@ -74,9 +74,9 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
: FillerKind.None, : FillerKind.None,
CustomTitle = scheduleItem.CustomTitle CustomTitle = scheduleItem.CustomTitle
}; };
durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime); durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc); DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc);
DateTimeOffset itemEndTimeWithFiller = CalculateEndTimeWithFiller( DateTimeOffset itemEndTimeWithFiller = CalculateEndTimeWithFiller(
collectionEnumerators, collectionEnumerators,
@ -192,7 +192,7 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
} }
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup }; nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
return Tuple(nextState, playoutItems); return Tuple(nextState, playoutItems);
} }
} }

8
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs

@ -73,7 +73,7 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
{ {
playoutItems.AddRange( playoutItems.AddRange(
AddFiller(nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters)); AddFiller(nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters));
LogScheduledItem(scheduleItem, mediaItem, itemStartTime); // LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
DateTimeOffset actualEndTime = playoutItems.Max(p => p.FinishOffset); DateTimeOffset actualEndTime = playoutItems.Max(p => p.FinishOffset);
if (Math.Abs((itemEndTimeWithFiller - actualEndTime).TotalSeconds) > 1) if (Math.Abs((itemEndTimeWithFiller - actualEndTime).TotalSeconds) > 1)
@ -97,9 +97,9 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
} }
} }
_logger.LogDebug( // _logger.LogDebug(
"Advancing to next schedule item after playout mode {PlayoutMode}", // "Advancing to next schedule item after playout mode {PlayoutMode}",
"Flood"); // "Flood");
nextState = nextState with nextState = nextState with
{ {

8
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs

@ -24,7 +24,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
DateTimeOffset hardStop) DateTimeOffset hardStop)
{ {
var playoutItems = new List<PlayoutItem>(); var playoutItems = new List<PlayoutItem>();
PlayoutBuilderState nextState = playoutBuilderState with PlayoutBuilderState nextState = playoutBuilderState with
{ {
MultipleRemaining = playoutBuilderState.MultipleRemaining.IfNone(scheduleItem.Count) MultipleRemaining = playoutBuilderState.MultipleRemaining.IfNone(scheduleItem.Count)
@ -74,7 +74,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
playoutItems.AddRange( playoutItems.AddRange(
AddFiller(nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters)); AddFiller(nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters));
nextState = nextState with nextState = nextState with
{ {
CurrentTime = itemEndTimeWithFiller, CurrentTime = itemEndTimeWithFiller,
@ -99,7 +99,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
nextState.ScheduleItemsEnumerator.MoveNext(); nextState.ScheduleItemsEnumerator.MoveNext();
} }
DateTimeOffset nextItemStart = GetStartTimeAfter(nextState, nextScheduleItem); DateTimeOffset nextItemStart = GetStartTimeAfter(nextState, nextScheduleItem);
if (scheduleItem.TailFiller != null) if (scheduleItem.TailFiller != null)
@ -121,7 +121,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
playoutItems, playoutItems,
nextItemStart); nextItemStart);
} }
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup }; nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
return Tuple(nextState, playoutItems); return Tuple(nextState, playoutItems);

12
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs

@ -57,11 +57,6 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
playoutItem, playoutItem,
itemChapters); itemChapters);
// only play one item from collection, so always advance to the next item
// _logger.LogDebug(
// "Advancing to next schedule item after playout mode {PlayoutMode}",
// "One");
PlayoutBuilderState nextState = playoutBuilderState with PlayoutBuilderState nextState = playoutBuilderState with
{ {
CurrentTime = itemEndTimeWithFiller CurrentTime = itemEndTimeWithFiller
@ -72,6 +67,11 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime); // LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
// only play one item from collection, so always advance to the next item
// _logger.LogDebug(
// "Advancing to next schedule item after playout mode {PlayoutMode}",
// "One");
DateTimeOffset nextItemStart = GetStartTimeAfter(nextState, nextScheduleItem); DateTimeOffset nextItemStart = GetStartTimeAfter(nextState, nextScheduleItem);
if (scheduleItem.TailFiller != null) if (scheduleItem.TailFiller != null)
@ -98,7 +98,7 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
return Tuple(nextState, playoutItems); return Tuple(nextState, playoutItems);
} }
return Tuple(playoutBuilderState, new List<PlayoutItem>()); return Tuple(playoutBuilderState, new List<PlayoutItem>());
} }
} }

8
ErsatzTV.Core/Scheduling/PlayoutParameters.cs

@ -0,0 +1,8 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Scheduling;
public record PlayoutParameters(
DateTimeOffset Start,
DateTimeOffset Finish,
Map<CollectionKey, List<MediaItem>> CollectionMediaItems);

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

@ -29,5 +29,8 @@ public class PlayoutProgramScheduleAnchorConfiguration : IEntityTypeConfiguratio
.HasForeignKey(i => i.MediaItemId) .HasForeignKey(i => i.MediaItemId)
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(false); .IsRequired(false);
builder.Property(i => i.AnchorDate)
.IsRequired(false);
} }
} }

3864
ErsatzTV.Infrastructure/Migrations/20220209084802_Add_PlayoutProgramScheduleAnchor_AnchorDate.Designer.cs generated

File diff suppressed because it is too large Load Diff

26
ErsatzTV.Infrastructure/Migrations/20220209084802_Add_PlayoutProgramScheduleAnchor_AnchorDate.cs

@ -0,0 +1,26 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_PlayoutProgramScheduleAnchor_AnchorDate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "AnchorDate",
table: "PlayoutProgramScheduleAnchor",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AnchorDate",
table: "PlayoutProgramScheduleAnchor");
}
}
}

3
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -1346,6 +1346,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<DateTime?>("AnchorDate")
.HasColumnType("TEXT");
b.Property<int?>("CollectionId") b.Property<int?>("CollectionId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

36
ErsatzTV/Pages/Playouts.razor

@ -2,6 +2,7 @@
@using ErsatzTV.Application.Playouts @using ErsatzTV.Application.Playouts
@using ErsatzTV.Application.Configuration @using ErsatzTV.Application.Configuration
@implements IDisposable @implements IDisposable
@using ErsatzTV.Core.Scheduling
@inject IDialogService _dialog @inject IDialogService _dialog
@inject IMediator _mediator @inject IMediator _mediator
@ -44,14 +45,14 @@
@* <MudTd DataLabel="Playout Type">@context.ProgramSchedulePlayoutType</MudTd> *@ @* <MudTd DataLabel="Playout Type">@context.ProgramSchedulePlayoutType</MudTd> *@
<MudTd> <MudTd>
<div style="align-items: center; display: flex;"> <div style="align-items: center; display: flex;">
<MudTooltip Text="Rebuild Playout"> <MudTooltip Text="Reset Playout">
<MudIconButton Icon="@Icons.Material.Filled.Refresh" <MudIconButton Icon="@Icons.Material.Filled.Refresh"
OnClick="@(_ => RebuildPlayout(context))"> OnClick="@(_ => ResetPlayout(context))">
</MudIconButton> </MudIconButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="Schedule Rebuild"> <MudTooltip Text="Schedule Reset">
<MudIconButton Icon="@Icons.Material.Filled.Update" <MudIconButton Icon="@Icons.Material.Filled.Update"
OnClick="@(_ => ScheduleRebuild(context))"> OnClick="@(_ => ScheduleReset(context))">
</MudIconButton> </MudIconButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="Delete Playout"> <MudTooltip Text="Delete Playout">
@ -114,11 +115,14 @@
if (_showFiller != value) if (_showFiller != value)
{ {
_showFiller = value; _showFiller = value;
_detailTable.ReloadServerData(); if (_selectedPlayoutId != null)
{
_detailTable.ReloadServerData();
}
} }
} }
} }
public void Dispose() public void Dispose()
{ {
_cts.Cancel(); _cts.Cancel();
@ -141,14 +145,6 @@
await _detailTable.ReloadServerData(); await _detailTable.ReloadServerData();
} }
private async Task ShowFillerChanged()
{
if (_selectedPlayoutId != null)
{
await _detailTable.ReloadServerData();
}
}
private async Task DeletePlayout(PlayoutNameViewModel playout) private async Task DeletePlayout(PlayoutNameViewModel playout)
{ {
var parameters = new DialogParameters { { "EntityType", "playout" }, { "EntityName", $"{playout.ScheduleName} on {playout.ChannelNumber} - {playout.ChannelName}" } }; var parameters = new DialogParameters { { "EntityType", "playout" }, { "EntityName", $"{playout.ScheduleName} on {playout.ChannelNumber} - {playout.ChannelName}" } };
@ -167,9 +163,9 @@
} }
} }
private async Task RebuildPlayout(PlayoutNameViewModel playout) private async Task ResetPlayout(PlayoutNameViewModel playout)
{ {
await _mediator.Send(new BuildPlayout(playout.PlayoutId, true), _cts.Token); await _mediator.Send(new BuildPlayout(playout.PlayoutId, PlayoutBuildMode.Reset), _cts.Token);
await _table.ReloadServerData(); await _table.ReloadServerData();
if (_selectedPlayoutId == playout.PlayoutId) if (_selectedPlayoutId == playout.PlayoutId)
{ {
@ -177,19 +173,21 @@
} }
} }
private async Task ScheduleRebuild(PlayoutNameViewModel playout) private async Task ScheduleReset(PlayoutNameViewModel playout)
{ {
var parameters = new DialogParameters var parameters = new DialogParameters
{ {
{ "PlayoutId", playout.PlayoutId }, { "PlayoutId", playout.PlayoutId },
{ "ChannelName", playout.ChannelName }, { "ChannelName", playout.ChannelName },
{ "ScheduleName", playout.ScheduleName }, { "ScheduleName", playout.ScheduleName },
{ "DailyRebuildTime", playout.DailyRebuildTime } { "DailyResetTime", playout.DailyRebuildTime }
}; };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = _dialog.Show<SchedulePlayoutRebuild>("Schedule Playout Rebuild", parameters, options); IDialogReference dialog = _dialog.Show<SchedulePlayoutReset>("Schedule Playout Reset", parameters, options);
await dialog.Result; await dialog.Result;
await _table.ReloadServerData();
} }
private async Task<TableData<PlayoutNameViewModel>> ServerReload(TableState state) private async Task<TableData<PlayoutNameViewModel>> ServerReload(TableState state)

2
ErsatzTV/Program.cs

@ -1,3 +1,4 @@
using Destructurama;
using ErsatzTV.Core; using ErsatzTV.Core;
using Serilog; using Serilog;
@ -35,6 +36,7 @@ public class Program
{ {
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(Configuration) .ReadFrom.Configuration(Configuration)
.Destructure.UsingAttributes()
.Enrich.FromLogContext() .Enrich.FromLogContext()
.WriteTo.SQLite(FileSystemLayout.LogDatabasePath, retentionPeriod: TimeSpan.FromDays(1)) .WriteTo.SQLite(FileSystemLayout.LogDatabasePath, retentionPeriod: TimeSpan.FromDays(1))
.WriteTo.File(FileSystemLayout.LogFilePath, rollingInterval: RollingInterval.Day) .WriteTo.File(FileSystemLayout.LogFilePath, rollingInterval: RollingInterval.Day)

20
ErsatzTV/Services/SchedulerService.cs

@ -9,6 +9,7 @@ using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Search; using ErsatzTV.Application.Search;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -39,7 +40,7 @@ public class SchedulerService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{ {
DateTime firstRun = DateTime.Now; DateTime firstRun = DateTime.Now;
// run once immediately at startup // run once immediately at startup
if (!cancellationToken.IsCancellationRequested) if (!cancellationToken.IsCancellationRequested)
{ {
@ -66,9 +67,10 @@ public class SchedulerService : BackgroundService
var roundedMinute = (int)(Math.Round(DateTime.Now.Minute / 5.0) * 5); var roundedMinute = (int)(Math.Round(DateTime.Now.Minute / 5.0) * 5);
if (roundedMinute % 30 == 0) if (roundedMinute % 30 == 0)
{ {
// check for playouts to rebuild every 30 minutes // check for playouts to reset every 30 minutes
await RebuildPlayouts(cancellationToken); await ResetPlayouts(cancellationToken);
} }
if (roundedMinute % 60 == 0 && DateTime.Now.Subtract(firstRun) > TimeSpan.FromHours(1)) if (roundedMinute % 60 == 0 && DateTime.Now.Subtract(firstRun) > TimeSpan.FromHours(1))
{ {
// do other work every hour (on the hour) // do other work every hour (on the hour)
@ -112,7 +114,7 @@ public class SchedulerService : BackgroundService
} }
} }
private async Task RebuildPlayouts(CancellationToken cancellationToken) private async Task ResetPlayouts(CancellationToken cancellationToken)
{ {
try try
{ {
@ -129,14 +131,16 @@ public class SchedulerService : BackgroundService
if (DateTime.Now.Subtract(DateTime.Today.Add(playout.DailyRebuildTime ?? TimeSpan.FromDays(7))) < if (DateTime.Now.Subtract(DateTime.Today.Add(playout.DailyRebuildTime ?? TimeSpan.FromDays(7))) <
TimeSpan.FromMinutes(5)) TimeSpan.FromMinutes(5))
{ {
await _workerChannel.WriteAsync(new BuildPlayout(playout.Id, true), cancellationToken); await _workerChannel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
} }
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Error during scheduler run"); _logger.LogWarning(ex, "Error during scheduler run");
try try
{ {
using (IServiceScope scope = _serviceScopeFactory.CreateScope()) using (IServiceScope scope = _serviceScopeFactory.CreateScope())
@ -162,7 +166,9 @@ public class SchedulerService : BackgroundService
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id)) foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
{ {
await _workerChannel.WriteAsync(new BuildPlayout(playoutId), cancellationToken); await _workerChannel.WriteAsync(
new BuildPlayout(playoutId, PlayoutBuildMode.Continue),
cancellationToken);
} }
} }

20
ErsatzTV/Shared/SchedulePlayoutRebuild.razor → ErsatzTV/Shared/SchedulePlayoutReset.razor

@ -2,7 +2,7 @@
@implements IDisposable @implements IDisposable
@inject IMediator _mediator @inject IMediator _mediator
@inject ISnackbar _snackbar @inject ISnackbar _snackbar
@inject ILogger<SchedulePlayoutRebuild> _logger @inject ILogger<SchedulePlayoutReset> _logger
<MudDialog> <MudDialog>
<DialogContent> <DialogContent>
@ -12,8 +12,8 @@
@FormatText() @FormatText()
</MudText> </MudText>
</MudContainer> </MudContainer>
<MudSelect Class="mb-6 mx-4" Label="Daily Rebuild Time" @bind-Value="_rebuildTime"> <MudSelect Class="mb-6 mx-4" Label="Daily Reset Time" @bind-Value="_resetTime">
<MudSelectItem Value="@(Option<TimeSpan>.None)">Do not automatically rebuild</MudSelectItem> <MudSelectItem Value="@(Option<TimeSpan>.None)">Do not automatically reset</MudSelectItem>
@for (var i = 1; i < 48; i++) @for (var i = 1; i < 48; i++)
{ {
var time = TimeSpan.FromHours(i * 0.5); var time = TimeSpan.FromHours(i * 0.5);
@ -39,7 +39,7 @@
[Parameter] [Parameter]
public int PlayoutId { get; set; } public int PlayoutId { get; set; }
[Parameter] [Parameter]
public string ChannelName { get; set; } public string ChannelName { get; set; }
@ -47,16 +47,16 @@
public string ScheduleName { get; set; } public string ScheduleName { get; set; }
[Parameter] [Parameter]
public Option<TimeSpan> DailyRebuildTime { get; set; } public Option<TimeSpan> DailyResetTime { get; set; }
private string FormatText() => $"Enter the time that the playout on channel {ChannelName} with schedule {ScheduleName} should rebuild every day"; private string FormatText() => $"Enter the time that the playout on channel {ChannelName} with schedule {ScheduleName} should reset every day";
private record DummyModel; private record DummyModel;
private readonly DummyModel _dummyModel = new(); private readonly DummyModel _dummyModel = new();
private Option<TimeSpan> _rebuildTime; private Option<TimeSpan> _resetTime;
public void Dispose() public void Dispose()
{ {
_cts.Cancel(); _cts.Cancel();
@ -65,13 +65,13 @@
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
_rebuildTime = DailyRebuildTime; _resetTime = DailyResetTime;
} }
private async Task Submit() private async Task Submit()
{ {
Either<BaseError, PlayoutNameViewModel> maybeResult = Either<BaseError, PlayoutNameViewModel> maybeResult =
await _mediator.Send(new UpdatePlayout(PlayoutId, _rebuildTime), _cts.Token); await _mediator.Send(new UpdatePlayout(PlayoutId, _resetTime), _cts.Token);
maybeResult.Match( maybeResult.Match(
playout => { MudDialog.Close(DialogResult.Ok(playout)); }, playout => { MudDialog.Close(DialogResult.Ok(playout)); },
Loading…
Cancel
Save