mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* add template playout kind * add template scheduler count * implement pad to next * only allow resetting template playouts * update changelogpull/1809/head
42 changed files with 12371 additions and 68 deletions
@ -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"); |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Playouts; |
||||||
|
|
||||||
|
public record UpdateTemplatePlayout(int PlayoutId, string TemplateFile) |
||||||
|
: IRequest<Either<BaseError, PlayoutNameViewModel>>; |
||||||
@ -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.")); |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
namespace ErsatzTV.Core.Scheduling.TemplateScheduling; |
||||||
|
|
||||||
|
public class PlayoutTemplate |
||||||
|
{ |
||||||
|
public List<PlayoutTemplateContentSearchItem> Content { get; set; } = []; |
||||||
|
public List<PlayoutTemplateItem> Playout { get; set; } = []; |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
namespace ErsatzTV.Core.Scheduling.TemplateScheduling; |
||||||
|
|
||||||
|
public class PlayoutTemplateContent |
||||||
|
{ |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
namespace ErsatzTV.Core.Scheduling.TemplateScheduling; |
||||||
|
|
||||||
|
public class PlayoutTemplateContentItem |
||||||
|
{ |
||||||
|
public string Key { get; set; } |
||||||
|
public string Order { get; set; } |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
namespace ErsatzTV.Core.Scheduling.TemplateScheduling; |
||||||
|
|
||||||
|
public class PlayoutTemplateContentSearchItem : PlayoutTemplateContentItem |
||||||
|
{ |
||||||
|
public string Search { get; set; } |
||||||
|
public string Query { get; set; } |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
namespace ErsatzTV.Core.Scheduling.TemplateScheduling; |
||||||
|
|
||||||
|
public class PlayoutTemplateCountItem : PlayoutTemplateItem |
||||||
|
{ |
||||||
|
public int Count { get; set; } |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
namespace ErsatzTV.Core.Scheduling.TemplateScheduling; |
||||||
|
|
||||||
|
public class PlayoutTemplateItem |
||||||
|
{ |
||||||
|
public string Content { get; set; } |
||||||
|
} |
||||||
@ -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; } |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
namespace ErsatzTV.Core.Scheduling.TemplateScheduling; |
||||||
|
|
||||||
|
public class PlayoutTemplatePlayout |
||||||
|
{ |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
namespace ErsatzTV.Core.Scheduling.TemplateScheduling; |
||||||
|
|
||||||
|
public class PlayoutTemplateRepeatItem : PlayoutTemplateItem |
||||||
|
{ |
||||||
|
public bool Repeat { get; set; } |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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(); |
||||||
|
} |
||||||
Loading…
Reference in new issue