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
This commit is contained in:
@@ -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
|
- Default filler will stop scheduling when the next item would extend into primary content
|
||||||
- Alternatively, default filler can be configured to `Trim To Fit`
|
- 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
|
- 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
|
### Fixed
|
||||||
- Add basic cache busting to XMLTV image URLs
|
- Add basic cache busting to XMLTV image URLs
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
|||||||
{
|
{
|
||||||
private readonly IBlockPlayoutBuilder _blockPlayoutBuilder;
|
private readonly IBlockPlayoutBuilder _blockPlayoutBuilder;
|
||||||
private readonly IBlockPlayoutFillerBuilder _blockPlayoutFillerBuilder;
|
private readonly IBlockPlayoutFillerBuilder _blockPlayoutFillerBuilder;
|
||||||
|
private readonly ITemplatePlayoutBuilder _templatePlayoutBuilder;
|
||||||
private readonly IClient _client;
|
private readonly IClient _client;
|
||||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
private readonly IEntityLocker _entityLocker;
|
private readonly IEntityLocker _entityLocker;
|
||||||
@@ -33,6 +34,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
|||||||
IPlayoutBuilder playoutBuilder,
|
IPlayoutBuilder playoutBuilder,
|
||||||
IBlockPlayoutBuilder blockPlayoutBuilder,
|
IBlockPlayoutBuilder blockPlayoutBuilder,
|
||||||
IBlockPlayoutFillerBuilder blockPlayoutFillerBuilder,
|
IBlockPlayoutFillerBuilder blockPlayoutFillerBuilder,
|
||||||
|
ITemplatePlayoutBuilder templatePlayoutBuilder,
|
||||||
IExternalJsonPlayoutBuilder externalJsonPlayoutBuilder,
|
IExternalJsonPlayoutBuilder externalJsonPlayoutBuilder,
|
||||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||||
IEntityLocker entityLocker,
|
IEntityLocker entityLocker,
|
||||||
@@ -43,6 +45,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
|||||||
_playoutBuilder = playoutBuilder;
|
_playoutBuilder = playoutBuilder;
|
||||||
_blockPlayoutBuilder = blockPlayoutBuilder;
|
_blockPlayoutBuilder = blockPlayoutBuilder;
|
||||||
_blockPlayoutFillerBuilder = blockPlayoutFillerBuilder;
|
_blockPlayoutFillerBuilder = blockPlayoutFillerBuilder;
|
||||||
|
_templatePlayoutBuilder = templatePlayoutBuilder;
|
||||||
_externalJsonPlayoutBuilder = externalJsonPlayoutBuilder;
|
_externalJsonPlayoutBuilder = externalJsonPlayoutBuilder;
|
||||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||||
_entityLocker = entityLocker;
|
_entityLocker = entityLocker;
|
||||||
@@ -74,6 +77,9 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
|||||||
await _blockPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
await _blockPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
||||||
await _blockPlayoutFillerBuilder.Build(playout, request.Mode, cancellationToken);
|
await _blockPlayoutFillerBuilder.Build(playout, request.Mode, cancellationToken);
|
||||||
break;
|
break;
|
||||||
|
case ProgramSchedulePlayoutType.Template:
|
||||||
|
await _templatePlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
||||||
|
break;
|
||||||
case ProgramSchedulePlayoutType.ExternalJson:
|
case ProgramSchedulePlayoutType.ExternalJson:
|
||||||
await _externalJsonPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
await _externalJsonPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -12,5 +12,8 @@ public record CreateFloodPlayout(int ChannelId, int ProgramScheduleId)
|
|||||||
public record CreateBlockPlayout(int ChannelId)
|
public record CreateBlockPlayout(int ChannelId)
|
||||||
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Block);
|
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Block);
|
||||||
|
|
||||||
|
public record CreateTemplatePlayout(int ChannelId, string TemplateFile)
|
||||||
|
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Template);
|
||||||
|
|
||||||
public record CreateExternalJsonPlayout(int ChannelId, string ExternalJsonFile)
|
public record CreateExternalJsonPlayout(int ChannelId, string ExternalJsonFile)
|
||||||
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.ExternalJson);
|
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.ExternalJson);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ public class
|
|||||||
playout.Channel.Number,
|
playout.Channel.Number,
|
||||||
playout.Channel.ProgressMode,
|
playout.Channel.ProgressMode,
|
||||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||||
|
playout.TemplateFile,
|
||||||
playout.ExternalJsonFile,
|
playout.ExternalJsonFile,
|
||||||
playout.DailyRebuildTime);
|
playout.DailyRebuildTime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
|||||||
playout.Channel.Number,
|
playout.Channel.Number,
|
||||||
playout.Channel.ProgressMode,
|
playout.Channel.ProgressMode,
|
||||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||||
|
playout.TemplateFile,
|
||||||
playout.ExternalJsonFile,
|
playout.ExternalJsonFile,
|
||||||
playout.DailyRebuildTime);
|
playout.DailyRebuildTime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."));
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public record PlayoutNameViewModel(
|
|||||||
string ChannelNumber,
|
string ChannelNumber,
|
||||||
ChannelProgressMode ProgressMode,
|
ChannelProgressMode ProgressMode,
|
||||||
string ScheduleName,
|
string ScheduleName,
|
||||||
|
string TemplateFile,
|
||||||
string ExternalJsonFile,
|
string ExternalJsonFile,
|
||||||
TimeSpan? DbDailyRebuildTime)
|
TimeSpan? DbDailyRebuildTime)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class GetAllPlayoutsHandler : IRequestHandler<GetAllPlayouts, List<Playou
|
|||||||
p.Channel.Number,
|
p.Channel.Number,
|
||||||
p.Channel.ProgressMode,
|
p.Channel.ProgressMode,
|
||||||
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
|
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
|
||||||
|
p.TemplateFile,
|
||||||
p.ExternalJsonFile,
|
p.ExternalJsonFile,
|
||||||
p.DailyRebuildTime))
|
p.DailyRebuildTime))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
|
|||||||
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
|
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>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
|
||||||
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask();
|
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<MediaItem>> GetPlaylistItems(int id) => throw new NotSupportedException();
|
||||||
public Task<List<Movie>> GetMovie(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();
|
public Task<List<Episode>> GetEpisode(int id) => throw new NotSupportedException();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class Playout
|
|||||||
public int? ProgramScheduleId { get; set; }
|
public int? ProgramScheduleId { get; set; }
|
||||||
public ProgramSchedule ProgramSchedule { get; set; }
|
public ProgramSchedule ProgramSchedule { get; set; }
|
||||||
public string ExternalJsonFile { get; set; }
|
public string ExternalJsonFile { get; set; }
|
||||||
|
public string TemplateFile { get; set; }
|
||||||
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
|
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
|
||||||
public ProgramSchedulePlayoutType ProgramSchedulePlayoutType { get; set; }
|
public ProgramSchedulePlayoutType ProgramSchedulePlayoutType { get; set; }
|
||||||
public List<PlayoutItem> Items { get; set; }
|
public List<PlayoutItem> Items { get; set; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public enum ProgramSchedulePlayoutType
|
|||||||
None = 0,
|
None = 0,
|
||||||
Flood = 1,
|
Flood = 1,
|
||||||
Block = 2,
|
Block = 2,
|
||||||
|
Template = 3,
|
||||||
|
|
||||||
ExternalJson = 20
|
ExternalJson = 20
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageReference Include="Serilog" Version="4.0.0" />
|
<PackageReference Include="Serilog" Version="4.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageReference Include="YamlDotNet" Version="16.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public interface IMediaCollectionRepository
|
|||||||
Task<List<MediaItem>> GetItems(int id);
|
Task<List<MediaItem>> GetItems(int id);
|
||||||
Task<List<MediaItem>> GetMultiCollectionItems(int id);
|
Task<List<MediaItem>> GetMultiCollectionItems(int id);
|
||||||
Task<List<MediaItem>> GetSmartCollectionItems(int id);
|
Task<List<MediaItem>> GetSmartCollectionItems(int id);
|
||||||
|
Task<List<MediaItem>> GetSmartCollectionItems(string query);
|
||||||
Task<List<MediaItem>> GetPlaylistItems(int id);
|
Task<List<MediaItem>> GetPlaylistItems(int id);
|
||||||
Task<List<Movie>> GetMovie(int id);
|
Task<List<Movie>> GetMovie(int id);
|
||||||
Task<List<Episode>> GetEpisode(int id);
|
Task<List<Episode>> GetEpisode(int id);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
Generated
+5860
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1714,6 +1714,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
|||||||
b.Property<int>("Seed")
|
b.Property<int>("Seed")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("TemplateFile")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ChannelId");
|
b.HasIndex("ChannelId");
|
||||||
|
|||||||
Generated
+5699
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1627,6 +1627,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
|||||||
b.Property<int>("Seed")
|
b.Property<int>("Seed")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TemplateFile")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ChannelId");
|
b.HasIndex("ChannelId");
|
||||||
|
|||||||
@@ -346,15 +346,25 @@ public class MediaCollectionRepository : IMediaCollectionRepository
|
|||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
var result = new List<MediaItem>();
|
|
||||||
|
|
||||||
Option<SmartCollection> maybeCollection = await dbContext.SmartCollections
|
Option<SmartCollection> maybeCollection = await dbContext.SmartCollections
|
||||||
.SelectOneAsync(sc => sc.Id, sc => sc.Id == id);
|
.SelectOneAsync(sc => sc.Id, sc => sc.Id == id);
|
||||||
|
|
||||||
foreach (SmartCollection collection in maybeCollection)
|
foreach (SmartCollection collection in maybeCollection)
|
||||||
{
|
{
|
||||||
|
return await GetSmartCollectionItems(collection.Query);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MediaItem>> GetSmartCollectionItems(string query)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var result = new List<MediaItem>();
|
||||||
|
|
||||||
// elasticsearch doesn't like when we ask for a limit of zero, so use 10,000
|
// 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);
|
SearchResult searchResults = await _searchIndex.Search(_client, query, 0, 10_000);
|
||||||
|
|
||||||
var movieIds = searchResults.Items
|
var movieIds = searchResults.Items
|
||||||
.Filter(i => i.Type == LuceneSearchIndex.MovieType)
|
.Filter(i => i.Type == LuceneSearchIndex.MovieType)
|
||||||
@@ -408,7 +418,6 @@ public class MediaCollectionRepository : IMediaCollectionRepository
|
|||||||
.Map(i => i.Id)
|
.Map(i => i.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
result.AddRange(await GetImageItems(dbContext, imageIds));
|
result.AddRange(await GetImageItems(dbContext, imageIds));
|
||||||
}
|
|
||||||
|
|
||||||
return result.DistinctBy(x => x.Id).ToList();
|
return result.DistinctBy(x => x.Id).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
case PlayoutKind.ExternalJson:
|
case PlayoutKind.ExternalJson:
|
||||||
<span>Add External Json Playout</span>
|
<span>Add External Json Playout</span>
|
||||||
break;
|
break;
|
||||||
|
case PlayoutKind.Template:
|
||||||
|
<span>Add Template Playout</span>
|
||||||
|
break;
|
||||||
case PlayoutKind.Block:
|
case PlayoutKind.Block:
|
||||||
<span>Add Block Playout</span>
|
<span>Add Block Playout</span>
|
||||||
break;
|
break;
|
||||||
@@ -39,12 +42,15 @@
|
|||||||
</MudSelectItem>
|
</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
@if (Kind == PlayoutKind.ExternalJson)
|
@switch (Kind)
|
||||||
{
|
{
|
||||||
|
case PlayoutKind.ExternalJson:
|
||||||
<MudTextField Label="External Json File" @bind-Value="_model.ExternalJsonFile" For="@(() => _model.ExternalJsonFile)"/>
|
<MudTextField Label="External Json File" @bind-Value="_model.ExternalJsonFile" For="@(() => _model.ExternalJsonFile)"/>
|
||||||
}
|
break;
|
||||||
else if (string.IsNullOrWhiteSpace(Kind))
|
case PlayoutKind.Template:
|
||||||
{
|
<MudTextField Label="Template File" @bind-Value="_model.TemplateFile" For="@(() => _model.TemplateFile)"/>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
<MudSelect Class="mt-3"
|
<MudSelect Class="mt-3"
|
||||||
T="ProgramScheduleViewModel"
|
T="ProgramScheduleViewModel"
|
||||||
Label="Schedule"
|
Label="Schedule"
|
||||||
@@ -54,6 +60,7 @@
|
|||||||
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
|
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
|
||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
</MudCardContent>
|
</MudCardContent>
|
||||||
<MudCardActions>
|
<MudCardActions>
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
Add Block Playout
|
Add Block Playout
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudTooltip>
|
</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">
|
<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}")">
|
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Warning" Href="@($"playouts/add/{PlayoutKind.ExternalJson}")">
|
||||||
Add External Json Playout
|
Add External Json Playout
|
||||||
@@ -63,6 +68,9 @@
|
|||||||
case ProgramSchedulePlayoutType.Block:
|
case ProgramSchedulePlayoutType.Block:
|
||||||
<span>Block</span>
|
<span>Block</span>
|
||||||
break;
|
break;
|
||||||
|
case ProgramSchedulePlayoutType.Template:
|
||||||
|
<span>Template</span>
|
||||||
|
break;
|
||||||
case ProgramSchedulePlayoutType.ExternalJson:
|
case ProgramSchedulePlayoutType.ExternalJson:
|
||||||
<span>External Json</span>
|
<span>External Json</span>
|
||||||
break;
|
break;
|
||||||
@@ -123,6 +131,22 @@
|
|||||||
<div style="width: 48px"></div>
|
<div style="width: 48px"></div>
|
||||||
<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)
|
else if (context.PlayoutType == ProgramSchedulePlayoutType.Block)
|
||||||
{
|
{
|
||||||
<MudTooltip Text="Edit Playout">
|
<MudTooltip Text="Edit Playout">
|
||||||
@@ -232,8 +256,8 @@
|
|||||||
|
|
||||||
private async Task PlayoutSelected(PlayoutNameViewModel playout)
|
private async Task PlayoutSelected(PlayoutNameViewModel playout)
|
||||||
{
|
{
|
||||||
// only show details for flood and block playouts
|
// only show details for flood, block and template playouts
|
||||||
_selectedPlayoutId = playout.PlayoutType is ProgramSchedulePlayoutType.Flood or ProgramSchedulePlayoutType.Block
|
_selectedPlayoutId = playout.PlayoutType is ProgramSchedulePlayoutType.Flood or ProgramSchedulePlayoutType.Block or ProgramSchedulePlayoutType.Template
|
||||||
? playout.PlayoutId
|
? playout.PlayoutId
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -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)
|
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}" } };
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ namespace ErsatzTV;
|
|||||||
public static class PlayoutKind
|
public static class PlayoutKind
|
||||||
{
|
{
|
||||||
public const string ExternalJson = "externaljson";
|
public const string ExternalJson = "externaljson";
|
||||||
|
public const string Template = "template";
|
||||||
public const string Block = "block";
|
public const string Block = "block";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ using ErsatzTV.Core.Metadata;
|
|||||||
using ErsatzTV.Core.Plex;
|
using ErsatzTV.Core.Plex;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
using ErsatzTV.Core.Scheduling.BlockScheduling;
|
using ErsatzTV.Core.Scheduling.BlockScheduling;
|
||||||
|
using ErsatzTV.Core.Scheduling.TemplateScheduling;
|
||||||
using ErsatzTV.Core.Trakt;
|
using ErsatzTV.Core.Trakt;
|
||||||
using ErsatzTV.FFmpeg.Capabilities;
|
using ErsatzTV.FFmpeg.Capabilities;
|
||||||
using ErsatzTV.FFmpeg.Pipeline;
|
using ErsatzTV.FFmpeg.Pipeline;
|
||||||
@@ -655,6 +656,7 @@ public class Startup
|
|||||||
services.AddScoped<IBlockPlayoutBuilder, BlockPlayoutBuilder>();
|
services.AddScoped<IBlockPlayoutBuilder, BlockPlayoutBuilder>();
|
||||||
services.AddScoped<IBlockPlayoutPreviewBuilder, BlockPlayoutPreviewBuilder>();
|
services.AddScoped<IBlockPlayoutPreviewBuilder, BlockPlayoutPreviewBuilder>();
|
||||||
services.AddScoped<IBlockPlayoutFillerBuilder, BlockPlayoutFillerBuilder>();
|
services.AddScoped<IBlockPlayoutFillerBuilder, BlockPlayoutFillerBuilder>();
|
||||||
|
services.AddScoped<ITemplatePlayoutBuilder, TemplatePlayoutBuilder>();
|
||||||
services.AddScoped<IExternalJsonPlayoutBuilder, ExternalJsonPlayoutBuilder>();
|
services.AddScoped<IExternalJsonPlayoutBuilder, ExternalJsonPlayoutBuilder>();
|
||||||
services.AddScoped<IPlayoutTimeShifter, PlayoutTimeShifter>();
|
services.AddScoped<IPlayoutTimeShifter, PlayoutTimeShifter>();
|
||||||
services.AddScoped<IImageCache, ImageCache>();
|
services.AddScoped<IImageCache, ImageCache>();
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ public class PlayoutEditViewModel
|
|||||||
public ChannelViewModel Channel { get; set; }
|
public ChannelViewModel Channel { get; set; }
|
||||||
public ProgramScheduleViewModel ProgramSchedule { get; set; }
|
public ProgramScheduleViewModel ProgramSchedule { get; set; }
|
||||||
public string ExternalJsonFile { get; set; }
|
public string ExternalJsonFile { get; set; }
|
||||||
|
public string TemplateFile { get; set; }
|
||||||
|
|
||||||
public CreatePlayout ToCreate() =>
|
public CreatePlayout ToCreate() =>
|
||||||
Kind switch
|
Kind switch
|
||||||
{
|
{
|
||||||
PlayoutKind.ExternalJson => new CreateExternalJsonPlayout(Channel.Id, ExternalJsonFile),
|
PlayoutKind.ExternalJson => new CreateExternalJsonPlayout(Channel.Id, ExternalJsonFile),
|
||||||
|
PlayoutKind.Template => new CreateTemplatePlayout(Channel.Id, TemplateFile),
|
||||||
PlayoutKind.Block => new CreateBlockPlayout(Channel.Id),
|
PlayoutKind.Block => new CreateBlockPlayout(Channel.Id),
|
||||||
_ => new CreateFloodPlayout(Channel.Id, ProgramSchedule.Id)
|
_ => new CreateFloodPlayout(Channel.Id, ProgramSchedule.Id)
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user