Browse Source

add experimental playout template system (#1808)

* add template playout kind

* add template scheduler count

* implement pad to next

* only allow resetting template playouts

* update changelog
pull/1809/head
Jason Dove 10 months ago committed by GitHub
parent
commit
d9a7615cf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      CHANGELOG.md
  2. 6
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  3. 3
      ErsatzTV.Application/Playouts/Commands/CreatePlayout.cs
  4. 91
      ErsatzTV.Application/Playouts/Commands/CreateTemplatePlayoutHandler.cs
  5. 1
      ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs
  6. 1
      ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs
  7. 6
      ErsatzTV.Application/Playouts/Commands/UpdateTemplatePlayout.cs
  8. 71
      ErsatzTV.Application/Playouts/Commands/UpdateTemplatePlayoutHandler.cs
  9. 1
      ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs
  10. 1
      ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs
  11. 1
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  12. 1
      ErsatzTV.Core/Domain/Playout.cs
  13. 1
      ErsatzTV.Core/Domain/ProgramSchedulePlayoutType.cs
  14. 1
      ErsatzTV.Core/ErsatzTV.Core.csproj
  15. 1
      ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs
  16. 9
      ErsatzTV.Core/Interfaces/Scheduling/ITemplatePlayoutBuilder.cs
  17. 7
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplate.cs
  18. 6
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateContent.cs
  19. 7
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateContentItem.cs
  20. 7
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateContentSearchItem.cs
  21. 6
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateCountItem.cs
  22. 6
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateItem.cs
  23. 11
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplatePadToNextItem.cs
  24. 5
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplatePlayout.cs
  25. 6
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateRepeatItem.cs
  26. 18
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateScheduler.cs
  27. 53
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerCount.cs
  28. 80
      ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerPadToNext.cs
  29. 161
      ErsatzTV.Core/Scheduling/TemplateScheduling/TemplatePlayoutBuilder.cs
  30. 5860
      ErsatzTV.Infrastructure.MySql/Migrations/20240726025833_Add_Playout_TemplateFile.Designer.cs
  31. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20240726025833_Add_Playout_TemplateFile.cs
  32. 3
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  33. 5699
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240726020713_Add_Playout_TemplateFile.Designer.cs
  34. 28
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240726020713_Add_Playout_TemplateFile.cs
  35. 3
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  36. 113
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  37. 35
      ErsatzTV/Pages/PlayoutEditor.razor
  38. 47
      ErsatzTV/Pages/Playouts.razor
  39. 1
      ErsatzTV/PlayoutKind.cs
  40. 42
      ErsatzTV/Shared/EditTemplateFileDialog.razor
  41. 2
      ErsatzTV/Startup.cs
  42. 2
      ErsatzTV/ViewModels/PlayoutEditViewModel.cs

7
CHANGELOG.md

@ -22,6 +22,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -22,6 +22,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Default filler will stop scheduling when the next item would extend into primary content
- Alternatively, default filler can be configured to `Trim To Fit`
- In this case, the last item that would extend into primary content is trimmed to end exactly when the primary content starts
- Add **experimental** playout type `Template`
- This playout type uses a YAML file to declare content and describe how the playout should be built
- Content currently supports search queries
- Playout instructions currently include `count`, `pad to next`, and `repeat`
- `count`: add the specified number of items (from the referenced content) to the playout
- `pad to next`: add items from the referenced content until the wall clock is a multiple of the specified minutes value
- `repeat`: continue building the playout from the first instruction in the template
### Fixed
- Add basic cache busting to XMLTV image URLs

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

@ -19,6 +19,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -19,6 +19,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{
private readonly IBlockPlayoutBuilder _blockPlayoutBuilder;
private readonly IBlockPlayoutFillerBuilder _blockPlayoutFillerBuilder;
private readonly ITemplatePlayoutBuilder _templatePlayoutBuilder;
private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
@ -33,6 +34,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -33,6 +34,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
IPlayoutBuilder playoutBuilder,
IBlockPlayoutBuilder blockPlayoutBuilder,
IBlockPlayoutFillerBuilder blockPlayoutFillerBuilder,
ITemplatePlayoutBuilder templatePlayoutBuilder,
IExternalJsonPlayoutBuilder externalJsonPlayoutBuilder,
IFFmpegSegmenterService ffmpegSegmenterService,
IEntityLocker entityLocker,
@ -43,6 +45,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -43,6 +45,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
_playoutBuilder = playoutBuilder;
_blockPlayoutBuilder = blockPlayoutBuilder;
_blockPlayoutFillerBuilder = blockPlayoutFillerBuilder;
_templatePlayoutBuilder = templatePlayoutBuilder;
_externalJsonPlayoutBuilder = externalJsonPlayoutBuilder;
_ffmpegSegmenterService = ffmpegSegmenterService;
_entityLocker = entityLocker;
@ -74,6 +77,9 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -74,6 +77,9 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
await _blockPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
await _blockPlayoutFillerBuilder.Build(playout, request.Mode, cancellationToken);
break;
case ProgramSchedulePlayoutType.Template:
await _templatePlayoutBuilder.Build(playout, request.Mode, cancellationToken);
break;
case ProgramSchedulePlayoutType.ExternalJson:
await _externalJsonPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
break;

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

@ -12,5 +12,8 @@ public record CreateFloodPlayout(int ChannelId, int ProgramScheduleId) @@ -12,5 +12,8 @@ public record CreateFloodPlayout(int ChannelId, int ProgramScheduleId)
public record CreateBlockPlayout(int ChannelId)
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Block);
public record CreateTemplatePlayout(int ChannelId, string TemplateFile)
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Template);
public record CreateExternalJsonPlayout(int ChannelId, string ExternalJsonFile)
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.ExternalJson);

91
ErsatzTV.Application/Playouts/Commands/CreateTemplatePlayoutHandler.cs

@ -0,0 +1,91 @@ @@ -0,0 +1,91 @@
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Playouts;
public class CreateTemplatePlayoutHandler
: IRequestHandler<CreateTemplatePlayout, Either<BaseError, CreatePlayoutResponse>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
public CreateTemplatePlayoutHandler(
ILocalFileSystem localFileSystem,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
{
_localFileSystem = localFileSystem;
_channel = channel;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
CreateTemplatePlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
}
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
{
await dbContext.Playouts.AddAsync(playout);
await dbContext.SaveChangesAsync();
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
await _channel.WriteAsync(new RefreshChannelList());
return new CreatePlayoutResponse(playout.Id);
}
private async Task<Validation<BaseError, Playout>> Validate(
TvContext dbContext,
CreateTemplatePlayout request) =>
(await ValidateChannel(dbContext, request), ValidateTemplateFile(request), ValidatePlayoutType(request))
.Apply(
(channel, externalJsonFile, playoutType) => new Playout
{
ChannelId = channel.Id,
TemplateFile = externalJsonFile,
ProgramSchedulePlayoutType = playoutType
});
private static Task<Validation<BaseError, Channel>> ValidateChannel(
TvContext dbContext,
CreateTemplatePlayout createTemplatePlayout) =>
dbContext.Channels
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == createTemplatePlayout.ChannelId)
.Map(o => o.ToValidation<BaseError>("Channel does not exist"))
.BindT(ChannelMustNotHavePlayouts);
private static Validation<BaseError, Channel> ChannelMustNotHavePlayouts(Channel channel) =>
Optional(channel.Playouts.Count)
.Filter(count => count == 0)
.Map(_ => channel)
.ToValidation<BaseError>("Channel already has one playout");
private Validation<BaseError, string> ValidateTemplateFile(CreateTemplatePlayout request)
{
if (!_localFileSystem.FileExists(request.TemplateFile))
{
return BaseError.New("Template file does not exist!");
}
return request.TemplateFile;
}
private static Validation<BaseError, ProgramSchedulePlayoutType> ValidatePlayoutType(
CreateTemplatePlayout createTemplatePlayout) =>
Optional(createTemplatePlayout.ProgramSchedulePlayoutType)
.Filter(playoutType => playoutType == ProgramSchedulePlayoutType.Template)
.ToValidation<BaseError>("[ProgramSchedulePlayoutType] must be Template");
}

1
ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs

@ -51,6 +51,7 @@ public class @@ -51,6 +51,7 @@ public class
playout.Channel.Number,
playout.Channel.ProgressMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.TemplateFile,
playout.ExternalJsonFile,
playout.DailyRebuildTime);
}

1
ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs

@ -43,6 +43,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr @@ -43,6 +43,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.Channel.Number,
playout.Channel.ProgressMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.TemplateFile,
playout.ExternalJsonFile,
playout.DailyRebuildTime);
}

6
ErsatzTV.Application/Playouts/Commands/UpdateTemplatePlayout.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Playouts;
public record UpdateTemplatePlayout(int PlayoutId, string TemplateFile)
: IRequest<Either<BaseError, PlayoutNameViewModel>>;

71
ErsatzTV.Application/Playouts/Commands/UpdateTemplatePlayoutHandler.cs

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class
UpdateTemplatePlayoutHandler : IRequestHandler<UpdateTemplatePlayout,
Either<BaseError, PlayoutNameViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdateTemplatePlayoutHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
_dbContextFactory = dbContextFactory;
_workerChannel = workerChannel;
}
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
UpdateTemplatePlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
}
private async Task<PlayoutNameViewModel> ApplyUpdateRequest(
TvContext dbContext,
UpdateTemplatePlayout request,
Playout playout)
{
playout.TemplateFile = request.TemplateFile;
if (await dbContext.SaveChangesAsync() > 0)
{
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number));
}
return new PlayoutNameViewModel(
playout.Id,
playout.ProgramSchedulePlayoutType,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.ProgressMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.TemplateFile,
playout.ExternalJsonFile,
playout.DailyRebuildTime);
}
private static Task<Validation<BaseError, Playout>> Validate(
TvContext dbContext,
UpdateTemplatePlayout request) =>
PlayoutMustExist(dbContext, request);
private static Task<Validation<BaseError, Playout>> PlayoutMustExist(
TvContext dbContext,
UpdateTemplatePlayout updatePlayout) =>
dbContext.Playouts
.Include(p => p.Channel)
.SelectOneAsync(p => p.Id, p => p.Id == updatePlayout.PlayoutId)
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
}

1
ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs

@ -9,6 +9,7 @@ public record PlayoutNameViewModel( @@ -9,6 +9,7 @@ public record PlayoutNameViewModel(
string ChannelNumber,
ChannelProgressMode ProgressMode,
string ScheduleName,
string TemplateFile,
string ExternalJsonFile,
TimeSpan? DbDailyRebuildTime)
{

1
ErsatzTV.Application/Playouts/Queries/GetAllPlayoutsHandler.cs

@ -27,6 +27,7 @@ public class GetAllPlayoutsHandler : IRequestHandler<GetAllPlayouts, List<Playou @@ -27,6 +27,7 @@ public class GetAllPlayoutsHandler : IRequestHandler<GetAllPlayouts, List<Playou
p.Channel.Number,
p.Channel.ProgressMode,
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
p.TemplateFile,
p.ExternalJsonFile,
p.DailyRebuildTime))
.ToListAsync(cancellationToken);

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

@ -22,6 +22,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository @@ -22,6 +22,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask();
public Task<List<MediaItem>> GetSmartCollectionItems(string query) => throw new NotSupportedException();
public Task<List<MediaItem>> GetPlaylistItems(int id) => throw new NotSupportedException();
public Task<List<Movie>> GetMovie(int id) => throw new NotSupportedException();
public Task<List<Episode>> GetEpisode(int id) => throw new NotSupportedException();

1
ErsatzTV.Core/Domain/Playout.cs

@ -10,6 +10,7 @@ public class Playout @@ -10,6 +10,7 @@ public class Playout
public int? ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; }
public string ExternalJsonFile { get; set; }
public string TemplateFile { get; set; }
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
public ProgramSchedulePlayoutType ProgramSchedulePlayoutType { get; set; }
public List<PlayoutItem> Items { get; set; }

1
ErsatzTV.Core/Domain/ProgramSchedulePlayoutType.cs

@ -5,6 +5,7 @@ public enum ProgramSchedulePlayoutType @@ -5,6 +5,7 @@ public enum ProgramSchedulePlayoutType
None = 0,
Flood = 1,
Block = 2,
Template = 3,
ExternalJson = 20
}

1
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -27,6 +27,7 @@ @@ -27,6 +27,7 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="YamlDotNet" Version="16.0.0" />
</ItemGroup>
<ItemGroup>

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

@ -11,6 +11,7 @@ public interface IMediaCollectionRepository @@ -11,6 +11,7 @@ public interface IMediaCollectionRepository
Task<List<MediaItem>> GetItems(int id);
Task<List<MediaItem>> GetMultiCollectionItems(int id);
Task<List<MediaItem>> GetSmartCollectionItems(int id);
Task<List<MediaItem>> GetSmartCollectionItems(string query);
Task<List<MediaItem>> GetPlaylistItems(int id);
Task<List<Movie>> GetMovie(int id);
Task<List<Episode>> GetEpisode(int id);

9
ErsatzTV.Core/Interfaces/Scheduling/ITemplatePlayoutBuilder.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface ITemplatePlayoutBuilder
{
Task<Playout> Build(Playout playout, PlayoutBuildMode mode, CancellationToken cancellationToken);
}

7
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplate.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplate
{
public List<PlayoutTemplateContentSearchItem> Content { get; set; } = [];
public List<PlayoutTemplateItem> Playout { get; set; } = [];
}

6
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateContent.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplateContent
{
}

7
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateContentItem.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplateContentItem
{
public string Key { get; set; }
public string Order { get; set; }
}

7
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateContentSearchItem.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplateContentSearchItem : PlayoutTemplateContentItem
{
public string Search { get; set; }
public string Query { get; set; }
}

6
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateCountItem.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplateCountItem : PlayoutTemplateItem
{
public int Count { get; set; }
}

6
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateItem.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplateItem
{
public string Content { get; set; }
}

11
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplatePadToNextItem.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using YamlDotNet.Serialization;
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplatePadToNextItem : PlayoutTemplateItem
{
[YamlMember(Alias = "pad_to_next", ApplyNamingConventions = false)]
public int PadToNext { get; set; }
public bool Trim { get; set; }
}

5
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplatePlayout.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplatePlayout
{
}

6
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateRepeatItem.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplateRepeatItem : PlayoutTemplateItem
{
public bool Repeat { get; set; }
}

18
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateScheduler.cs

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public abstract class PlayoutTemplateScheduler
{
protected static TimeSpan DurationForMediaItem(MediaItem mediaItem)
{
if (mediaItem is Image image)
{
return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds);
}
MediaVersion version = mediaItem.GetHeadVersion();
return version.Duration;
}
}

53
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerCount.cs

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplateSchedulerCount : PlayoutTemplateScheduler
{
public static DateTimeOffset Schedule(
Playout playout,
DateTimeOffset currentTime,
PlayoutTemplateCountItem count,
IMediaCollectionEnumerator enumerator)
{
for (int i = 0; i < count.Count; i++)
{
foreach (MediaItem mediaItem in enumerator.Current)
{
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
// create a playout item
var playoutItem = new PlayoutItem
{
MediaItemId = mediaItem.Id,
Start = currentTime.UtcDateTime,
Finish = currentTime.UtcDateTime + itemDuration,
InPoint = TimeSpan.Zero,
OutPoint = itemDuration,
FillerKind = FillerKind.None, //blockItem.IncludeInProgramGuide ? FillerKind.None : FillerKind.GuideMode,
//CustomTitle = scheduleItem.CustomTitle,
//WatermarkId = scheduleItem.WatermarkId,
//PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
//SubtitleMode = scheduleItem.SubtitleMode
//GuideGroup = effectiveBlock.TemplateItemId,
//GuideStart = effectiveBlock.Start.UtcDateTime,
//GuideFinish = blockFinish.UtcDateTime,
//BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey),
//CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
//CollectionEtag = collectionEtags[collectionKey]
};
playout.Items.Add(playoutItem);
currentTime += itemDuration;
enumerator.MoveNext();
}
}
return currentTime;
}
}

80
ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerPadToNext.cs

@ -0,0 +1,80 @@ @@ -0,0 +1,80 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class PlayoutTemplateSchedulerPadToNext : PlayoutTemplateScheduler
{
public static DateTimeOffset Schedule(
Playout playout,
DateTimeOffset currentTime,
PlayoutTemplatePadToNextItem padToNext,
IMediaCollectionEnumerator enumerator)
{
int currentMinute = currentTime.Minute;
int targetMinute = (currentMinute + padToNext.PadToNext - 1) / padToNext.PadToNext * padToNext.PadToNext;
DateTimeOffset almostTargetTime =
currentTime - TimeSpan.FromMinutes(currentMinute) + TimeSpan.FromMinutes(targetMinute);
var targetTime = new DateTimeOffset(
almostTargetTime.Year,
almostTargetTime.Month,
almostTargetTime.Day,
almostTargetTime.Hour,
almostTargetTime.Minute,
0,
almostTargetTime.Offset);
// ensure filler works for content less than one minute
if (targetTime <= currentTime)
targetTime = targetTime.AddMinutes(padToNext.PadToNext);
bool done = false;
TimeSpan remainingToFill = targetTime - currentTime;
while (!done && enumerator.Current.IsSome && remainingToFill > TimeSpan.Zero &&
remainingToFill >= enumerator.MinimumDuration)
{
foreach (MediaItem mediaItem in enumerator.Current)
{
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
var playoutItem = new PlayoutItem
{
MediaItemId = mediaItem.Id,
Start = currentTime.UtcDateTime,
Finish = currentTime.UtcDateTime + itemDuration,
InPoint = TimeSpan.Zero,
OutPoint = itemDuration,
//GuideGroup = playoutBuilderState.NextGuideGroup,
//FillerKind = fillerKind,
//DisableWatermarks = !allowWatermarks
};
if (remainingToFill - itemDuration >= TimeSpan.Zero)
{
remainingToFill -= itemDuration;
playout.Items.Add(playoutItem);
enumerator.MoveNext();
}
else if (padToNext.Trim)
{
// trim item to exactly fit
remainingToFill = TimeSpan.Zero;
playoutItem.Finish = targetTime.UtcDateTime;
playoutItem.OutPoint = playoutItem.Finish - playoutItem.Start;
playout.Items.Add(playoutItem);
enumerator.MoveNext();
}
else
{
// item won't fit; we're done for now
done = true;
}
}
}
return targetTime;
}
}

161
ErsatzTV.Core/Scheduling/TemplateScheduling/TemplatePlayoutBuilder.cs

@ -0,0 +1,161 @@ @@ -0,0 +1,161 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt.UnsafeValueAccess;
using Microsoft.Extensions.Logging;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
public class TemplatePlayoutBuilder(
ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository,
IMediaCollectionRepository mediaCollectionRepository,
ILogger<TemplatePlayoutBuilder> logger)
: ITemplatePlayoutBuilder
{
public async Task<Playout> Build(Playout playout, PlayoutBuildMode mode, CancellationToken cancellationToken)
{
if (!localFileSystem.FileExists(playout.TemplateFile))
{
logger.LogWarning("Playout template file {File} does not exist; aborting.", playout.TemplateFile);
return playout;
}
PlayoutTemplate playoutTemplate = await LoadTemplate(playout, cancellationToken);
DateTimeOffset start = DateTimeOffset.Now;
int daysToBuild = await GetDaysToBuild();
DateTimeOffset finish = start.AddDays(daysToBuild);
if (mode is not PlayoutBuildMode.Reset)
{
logger.LogWarning("Template playouts can only be reset; ignoring build mode {Mode}", mode.ToString());
return playout;
}
// these are only for reset
playout.Seed = new Random().Next();
playout.Items.Clear();
DateTimeOffset currentTime = start;
// load content and content enumerators on demand
Dictionary<string, IMediaCollectionEnumerator> enumerators = new();
var index = 0;
while (currentTime < finish)
{
if (index >= playoutTemplate.Playout.Count)
{
logger.LogInformation("Reached the end of the playout template; stopping");
break;
}
PlayoutTemplateItem playoutItem = playoutTemplate.Playout[index];
// repeat resets index into template playout
if (playoutItem is PlayoutTemplateRepeatItem)
{
index = 0;
continue;
}
if (!enumerators.TryGetValue(playoutItem.Content, out IMediaCollectionEnumerator enumerator))
{
Option<IMediaCollectionEnumerator> maybeEnumerator =
await GetEnumeratorForContent(playout, playoutItem.Content, playoutTemplate, cancellationToken);
if (maybeEnumerator.IsNone)
{
logger.LogWarning("Unable to locate content with key {Key}", playoutItem.Content);
continue;
}
foreach (IMediaCollectionEnumerator e in maybeEnumerator)
{
enumerator = maybeEnumerator.ValueUnsafe();
enumerators.Add(playoutItem.Content, enumerator);
}
}
switch (playoutItem)
{
case PlayoutTemplateCountItem count:
currentTime = PlayoutTemplateSchedulerCount.Schedule(playout, currentTime, count, enumerator);
break;
case PlayoutTemplatePadToNextItem padToNext:
currentTime = PlayoutTemplateSchedulerPadToNext.Schedule(
playout,
currentTime,
padToNext,
enumerator);
break;
}
index++;
}
return playout;
}
private async Task<int> GetDaysToBuild() =>
await configElementRepository
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
.IfNoneAsync(2);
private async Task<Option<IMediaCollectionEnumerator>> GetEnumeratorForContent(
Playout playout,
string contentKey,
PlayoutTemplate playoutTemplate,
CancellationToken cancellationToken)
{
int index = playoutTemplate.Content.FindIndex(c => c.Key == contentKey);
if (index < 0)
{
return Option<IMediaCollectionEnumerator>.None;
}
PlayoutTemplateContentSearchItem content = playoutTemplate.Content[index];
List<MediaItem> items = await mediaCollectionRepository.GetSmartCollectionItems(content.Query);
var state = new CollectionEnumeratorState { Seed = playout.Seed + index, Index = 0 };
switch (Enum.Parse<PlaybackOrder>(content.Order, true))
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
case PlaybackOrder.Shuffle:
// TODO: fix this
var groupedMediaItems = items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
return new ShuffledMediaCollectionEnumerator(groupedMediaItems, state, cancellationToken);
}
return Option<IMediaCollectionEnumerator>.None;
}
private static async Task<PlayoutTemplate> LoadTemplate(Playout playout, CancellationToken cancellationToken)
{
string yaml = await File.ReadAllTextAsync(playout.TemplateFile, cancellationToken);
IDeserializer deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeDiscriminatingNodeDeserializer(
o =>
{
var keyMappings = new Dictionary<string, Type>
{
{ "count", typeof(PlayoutTemplateCountItem) },
{ "pad_to_next", typeof(PlayoutTemplatePadToNextItem) },
{ "repeat", typeof(PlayoutTemplateRepeatItem) }
};
o.AddUniqueKeyTypeDiscriminator<PlayoutTemplateItem>(keyMappings);
})
.Build();
return deserializer.Deserialize<PlayoutTemplate>(yaml);
}
}

5860
ErsatzTV.Infrastructure.MySql/Migrations/20240726025833_Add_Playout_TemplateFile.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20240726025833_Add_Playout_TemplateFile.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_Playout_TemplateFile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "TemplateFile",
table: "Playout",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TemplateFile",
table: "Playout");
}
}
}

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

@ -1714,6 +1714,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1714,6 +1714,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("Seed")
.HasColumnType("int");
b.Property<string>("TemplateFile")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("ChannelId");

5699
ErsatzTV.Infrastructure.Sqlite/Migrations/20240726020713_Add_Playout_TemplateFile.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.Sqlite/Migrations/20240726020713_Add_Playout_TemplateFile.cs

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_Playout_TemplateFile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "TemplateFile",
table: "Playout",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TemplateFile",
table: "Playout");
}
}
}

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

@ -1627,6 +1627,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1627,6 +1627,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("Seed")
.HasColumnType("INTEGER");
b.Property<string>("TemplateFile")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ChannelId");

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

@ -346,70 +346,79 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -346,70 +346,79 @@ public class MediaCollectionRepository : IMediaCollectionRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
var result = new List<MediaItem>();
Option<SmartCollection> maybeCollection = await dbContext.SmartCollections
.SelectOneAsync(sc => sc.Id, sc => sc.Id == id);
foreach (SmartCollection collection in maybeCollection)
{
// elasticsearch doesn't like when we ask for a limit of zero, so use 10,000
SearchResult searchResults = await _searchIndex.Search(_client, collection.Query, 0, 10_000);
return await GetSmartCollectionItems(collection.Query);
}
var movieIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.MovieType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetMovieItems(dbContext, movieIds));
return [];
}
foreach (int showId in searchResults.Items.Filter(i => i.Type == LuceneSearchIndex.ShowType).Map(i => i.Id))
{
result.AddRange(await GetShowItemsFromShowId(dbContext, showId));
}
public async Task<List<MediaItem>> GetSmartCollectionItems(string query)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
foreach (int seasonId in searchResults.Items.Filter(i => i.Type == LuceneSearchIndex.SeasonType)
.Map(i => i.Id))
{
result.AddRange(await GetSeasonItemsFromSeasonId(dbContext, seasonId));
}
var result = new List<MediaItem>();
foreach (int artistId in searchResults.Items.Filter(i => i.Type == LuceneSearchIndex.ArtistType)
.Map(i => i.Id))
{
result.AddRange(await GetArtistItemsFromArtistId(dbContext, artistId));
}
// elasticsearch doesn't like when we ask for a limit of zero, so use 10,000
SearchResult searchResults = await _searchIndex.Search(_client, query, 0, 10_000);
var movieIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.MovieType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetMovieItems(dbContext, movieIds));
foreach (int showId in searchResults.Items.Filter(i => i.Type == LuceneSearchIndex.ShowType).Map(i => i.Id))
{
result.AddRange(await GetShowItemsFromShowId(dbContext, showId));
}
foreach (int seasonId in searchResults.Items.Filter(i => i.Type == LuceneSearchIndex.SeasonType)
.Map(i => i.Id))
{
result.AddRange(await GetSeasonItemsFromSeasonId(dbContext, seasonId));
}
var musicVideoIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.MusicVideoType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetMusicVideoItems(dbContext, musicVideoIds));
var episodeIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.EpisodeType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetEpisodeItems(dbContext, episodeIds));
var otherVideoIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.OtherVideoType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetOtherVideoItems(dbContext, otherVideoIds));
var songIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.SongType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetSongItems(dbContext, songIds));
var imageIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.ImageType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetImageItems(dbContext, imageIds));
foreach (int artistId in searchResults.Items.Filter(i => i.Type == LuceneSearchIndex.ArtistType)
.Map(i => i.Id))
{
result.AddRange(await GetArtistItemsFromArtistId(dbContext, artistId));
}
var musicVideoIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.MusicVideoType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetMusicVideoItems(dbContext, musicVideoIds));
var episodeIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.EpisodeType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetEpisodeItems(dbContext, episodeIds));
var otherVideoIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.OtherVideoType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetOtherVideoItems(dbContext, otherVideoIds));
var songIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.SongType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetSongItems(dbContext, songIds));
var imageIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.ImageType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetImageItems(dbContext, imageIds));
return result.DistinctBy(x => x.Id).ToList();
}

35
ErsatzTV/Pages/PlayoutEditor.razor

@ -15,6 +15,9 @@ @@ -15,6 +15,9 @@
case PlayoutKind.ExternalJson:
<span>Add External Json Playout</span>
break;
case PlayoutKind.Template:
<span>Add Template Playout</span>
break;
case PlayoutKind.Block:
<span>Add Block Playout</span>
break;
@ -39,21 +42,25 @@ @@ -39,21 +42,25 @@
</MudSelectItem>
}
</MudSelect>
@if (Kind == PlayoutKind.ExternalJson)
{
<MudTextField Label="External Json File" @bind-Value="_model.ExternalJsonFile" For="@(() => _model.ExternalJsonFile)"/>
}
else if (string.IsNullOrWhiteSpace(Kind))
@switch (Kind)
{
<MudSelect Class="mt-3"
T="ProgramScheduleViewModel"
Label="Schedule"
@bind-value="_model.ProgramSchedule">
@foreach (ProgramScheduleViewModel schedule in _programSchedules)
{
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
}
</MudSelect>
case PlayoutKind.ExternalJson:
<MudTextField Label="External Json File" @bind-Value="_model.ExternalJsonFile" For="@(() => _model.ExternalJsonFile)"/>
break;
case PlayoutKind.Template:
<MudTextField Label="Template File" @bind-Value="_model.TemplateFile" For="@(() => _model.TemplateFile)"/>
break;
default:
<MudSelect Class="mt-3"
T="ProgramScheduleViewModel"
Label="Schedule"
@bind-value="_model.ProgramSchedule">
@foreach (ProgramScheduleViewModel schedule in _programSchedules)
{
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
}
</MudSelect>
break;
}
</MudCardContent>
<MudCardActions>

47
ErsatzTV/Pages/Playouts.razor

@ -18,6 +18,11 @@ @@ -18,6 +18,11 @@
Add Block Playout
</MudButton>
</MudTooltip>
<MudTooltip Text="This feature is experimental">
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Warning" Href="@($"playouts/add/{PlayoutKind.Template}")">
Add Template Playout
</MudButton>
</MudTooltip>
<MudTooltip Text="This feature is experimental">
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Warning" Href="@($"playouts/add/{PlayoutKind.ExternalJson}")">
Add External Json Playout
@ -63,6 +68,9 @@ @@ -63,6 +68,9 @@
case ProgramSchedulePlayoutType.Block:
<span>Block</span>
break;
case ProgramSchedulePlayoutType.Template:
<span>Template</span>
break;
case ProgramSchedulePlayoutType.ExternalJson:
<span>External Json</span>
break;
@ -123,6 +131,22 @@ @@ -123,6 +131,22 @@
<div style="width: 48px"></div>
<div style="width: 48px"></div>
}
else if (context.PlayoutType == ProgramSchedulePlayoutType.Template)
{
<MudTooltip Text="Edit Template File">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => EditTemplateFile(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Reset Playout">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ResetPlayout(context))">
</MudIconButton>
</MudTooltip>
<div style="width: 48px"></div>
}
else if (context.PlayoutType == ProgramSchedulePlayoutType.Block)
{
<MudTooltip Text="Edit Playout">
@ -232,8 +256,8 @@ @@ -232,8 +256,8 @@
private async Task PlayoutSelected(PlayoutNameViewModel playout)
{
// only show details for flood and block playouts
_selectedPlayoutId = playout.PlayoutType is ProgramSchedulePlayoutType.Flood or ProgramSchedulePlayoutType.Block
// only show details for flood, block and template playouts
_selectedPlayoutId = playout.PlayoutType is ProgramSchedulePlayoutType.Flood or ProgramSchedulePlayoutType.Block or ProgramSchedulePlayoutType.Template
? playout.PlayoutId
: null;
@ -262,6 +286,25 @@ @@ -262,6 +286,25 @@
}
}
private async Task EditTemplateFile(PlayoutNameViewModel playout)
{
var parameters = new DialogParameters { { "TemplateFile", $"{playout.TemplateFile}" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge };
IDialogReference dialog = await Dialog.ShowAsync<EditTemplateFileDialog>("Edit Template File", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Canceled)
{
await Mediator.Send(new UpdateTemplatePlayout(playout.PlayoutId, result.Data as string ?? playout.TemplateFile), _cts.Token);
if (_table != null)
{
await _table.ReloadServerData();
}
_selectedPlayoutId = null;
}
}
private async Task DeletePlayout(PlayoutNameViewModel playout)
{
var parameters = new DialogParameters { { "EntityType", "playout" }, { "EntityName", $"{playout.ScheduleName} on {playout.ChannelNumber} - {playout.ChannelName}" } };

1
ErsatzTV/PlayoutKind.cs

@ -3,5 +3,6 @@ namespace ErsatzTV; @@ -3,5 +3,6 @@ namespace ErsatzTV;
public static class PlayoutKind
{
public const string ExternalJson = "externaljson";
public const string Template = "template";
public const string Block = "block";
}

42
ErsatzTV/Shared/EditTemplateFileDialog.razor

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
@implements IDisposable
<MudDialog>
<DialogContent>
<MudContainer Class="mb-6">
<MudText>
Edit the playout's template file
</MudText>
</MudContainer>
<MudTextField Label="Template File" @bind-Value="_templateFile"/>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" Disabled="@(string.IsNullOrWhiteSpace(_templateFile))" OnClick="Submit">
Save Changes
</MudButton>
</DialogActions>
</MudDialog>
@code {
private readonly CancellationTokenSource _cts = new();
[Parameter]
public string TemplateFile { get; set; }
[CascadingParameter]
MudDialogInstance MudDialog { get; set; }
private string _templateFile;
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
protected override void OnParametersSet() => _templateFile = TemplateFile;
private void Submit() => MudDialog.Close(DialogResult.Ok(_templateFile));
private void Cancel() => MudDialog.Cancel();
}

2
ErsatzTV/Startup.cs

@ -36,6 +36,7 @@ using ErsatzTV.Core.Metadata; @@ -36,6 +36,7 @@ using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Core.Scheduling.TemplateScheduling;
using ErsatzTV.Core.Trakt;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Pipeline;
@ -655,6 +656,7 @@ public class Startup @@ -655,6 +656,7 @@ public class Startup
services.AddScoped<IBlockPlayoutBuilder, BlockPlayoutBuilder>();
services.AddScoped<IBlockPlayoutPreviewBuilder, BlockPlayoutPreviewBuilder>();
services.AddScoped<IBlockPlayoutFillerBuilder, BlockPlayoutFillerBuilder>();
services.AddScoped<ITemplatePlayoutBuilder, TemplatePlayoutBuilder>();
services.AddScoped<IExternalJsonPlayoutBuilder, ExternalJsonPlayoutBuilder>();
services.AddScoped<IPlayoutTimeShifter, PlayoutTimeShifter>();
services.AddScoped<IImageCache, ImageCache>();

2
ErsatzTV/ViewModels/PlayoutEditViewModel.cs

@ -10,11 +10,13 @@ public class PlayoutEditViewModel @@ -10,11 +10,13 @@ public class PlayoutEditViewModel
public ChannelViewModel Channel { get; set; }
public ProgramScheduleViewModel ProgramSchedule { get; set; }
public string ExternalJsonFile { get; set; }
public string TemplateFile { get; set; }
public CreatePlayout ToCreate() =>
Kind switch
{
PlayoutKind.ExternalJson => new CreateExternalJsonPlayout(Channel.Id, ExternalJsonFile),
PlayoutKind.Template => new CreateTemplatePlayout(Channel.Id, TemplateFile),
PlayoutKind.Block => new CreateBlockPlayout(Channel.Id),
_ => new CreateFloodPlayout(Channel.Id, ProgramSchedule.Id)
};

Loading…
Cancel
Save