Browse Source

playout management ui improvements (#1298)

pull/1299/head
Jason Dove 2 years ago committed by GitHub
parent
commit
c554d83d60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 6
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  3. 18
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
  4. 4
      ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
  5. 27
      ErsatzTV.Infrastructure/Locking/EntityLocker.cs
  6. 51
      ErsatzTV/Pages/Playouts.razor

6
CHANGELOG.md

@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added
- Disable playout buttons and show spinning indicator when a playout is being modified (built/extended, or subtitles are being extracted)
- Automatically reload playout details table when playout build is complete
### Fixed
- Skip checking for subtitles to extract when subtitles are not enabled on a channel/schedule item
## [0.7.9-beta] - 2023-06-10 ## [0.7.9-beta] - 2023-06-10
### Added ### Added

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

@ -6,6 +6,7 @@ using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@ -19,6 +20,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
private readonly IClient _client; private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService; private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IEntityLocker _entityLocker;
private readonly IPlayoutBuilder _playoutBuilder; private readonly IPlayoutBuilder _playoutBuilder;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
@ -27,12 +29,14 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IPlayoutBuilder playoutBuilder, IPlayoutBuilder playoutBuilder,
IFFmpegSegmenterService ffmpegSegmenterService, IFFmpegSegmenterService ffmpegSegmenterService,
IEntityLocker entityLocker,
ChannelWriter<IBackgroundServiceRequest> workerChannel) ChannelWriter<IBackgroundServiceRequest> workerChannel)
{ {
_client = client; _client = client;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_playoutBuilder = playoutBuilder; _playoutBuilder = playoutBuilder;
_ffmpegSegmenterService = ffmpegSegmenterService; _ffmpegSegmenterService = ffmpegSegmenterService;
_entityLocker = entityLocker;
_workerChannel = workerChannel; _workerChannel = workerChannel;
} }
@ -64,6 +68,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number); _ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
} }
_entityLocker.UnlockPlayout(playout.Id);
Option<string> maybeChannelNumber = await dbContext.Connection Option<string> maybeChannelNumber = await dbContext.Connection
.QuerySingleOrDefaultAsync<string>( .QuerySingleOrDefaultAsync<string>(
@"select C.Number from Channel C @"select C.Number from Channel C

18
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs

@ -9,6 +9,7 @@ using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -21,17 +22,20 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger; private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public ExtractEmbeddedSubtitlesHandler( public ExtractEmbeddedSubtitlesHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IEntityLocker entityLocker,
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<ExtractEmbeddedSubtitlesHandler> logger) ILogger<ExtractEmbeddedSubtitlesHandler> logger)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_entityLocker = entityLocker;
_workerChannel = workerChannel; _workerChannel = workerChannel;
_logger = logger; _logger = logger;
} }
@ -70,7 +74,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
.AsNoTracking() .AsNoTracking()
.Filter( .Filter(
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None || p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None)) p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != null && psi.SubtitleMode != ChannelSubtitleMode.None))
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId.IfNone(-1)); .SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId.IfNone(-1));
playoutIdsToCheck.AddRange(requestedPlayout.Map(p => p.Id)); playoutIdsToCheck.AddRange(requestedPlayout.Map(p => p.Id));
@ -82,7 +86,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
.AsNoTracking() .AsNoTracking()
.Filter( .Filter(
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None || p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None)) p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != null && psi.SubtitleMode != ChannelSubtitleMode.None))
.Map(p => p.Id) .Map(p => p.Id)
.ToList(); .ToList();
} }
@ -101,6 +105,11 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
return Unit.Default; return Unit.Default;
} }
foreach (int playoutId in playoutIdsToCheck)
{
_entityLocker.LockPlayout(playoutId);
}
_logger.LogDebug("Checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck); _logger.LogDebug("Checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
// find all playout items in the next hour // find all playout items in the next hour
@ -154,6 +163,11 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
_logger.LogDebug("Done checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck); _logger.LogDebug("Done checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
foreach (int playoutId in playoutIdsToCheck)
{
_entityLocker.UnlockPlayout(playoutId);
}
return Unit.Default; return Unit.Default;
} }
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)

4
ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs

@ -7,6 +7,7 @@ public interface IEntityLocker
event EventHandler<Type> OnRemoteMediaSourceChanged; event EventHandler<Type> OnRemoteMediaSourceChanged;
event EventHandler OnTraktChanged; event EventHandler OnTraktChanged;
event EventHandler OnEmbyCollectionsChanged; event EventHandler OnEmbyCollectionsChanged;
event EventHandler<int> OnPlayoutChanged;
bool LockLibrary(int libraryId); bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId); bool UnlockLibrary(int libraryId);
bool IsLibraryLocked(int libraryId); bool IsLibraryLocked(int libraryId);
@ -22,4 +23,7 @@ public interface IEntityLocker
bool LockEmbyCollections(); bool LockEmbyCollections();
bool UnlockEmbyCollections(); bool UnlockEmbyCollections();
bool AreEmbyCollectionsLocked(); bool AreEmbyCollectionsLocked();
bool LockPlayout(int playoutId);
bool UnlockPlayout(int playoutId);
bool IsPlayoutLocked(int playoutId);
} }

27
ErsatzTV.Infrastructure/Locking/EntityLocker.cs

@ -6,6 +6,7 @@ namespace ErsatzTV.Infrastructure.Locking;
public class EntityLocker : IEntityLocker public class EntityLocker : IEntityLocker
{ {
private readonly ConcurrentDictionary<int, byte> _lockedLibraries; private readonly ConcurrentDictionary<int, byte> _lockedLibraries;
private readonly ConcurrentDictionary<int, byte> _lockedPlayouts;
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes; private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes;
private bool _embyCollections; private bool _embyCollections;
private bool _plex; private bool _plex;
@ -14,6 +15,7 @@ public class EntityLocker : IEntityLocker
public EntityLocker() public EntityLocker()
{ {
_lockedLibraries = new ConcurrentDictionary<int, byte>(); _lockedLibraries = new ConcurrentDictionary<int, byte>();
_lockedPlayouts = new ConcurrentDictionary<int, byte>();
_lockedRemoteMediaSourceTypes = new ConcurrentDictionary<Type, byte>(); _lockedRemoteMediaSourceTypes = new ConcurrentDictionary<Type, byte>();
} }
@ -22,6 +24,7 @@ public class EntityLocker : IEntityLocker
public event EventHandler<Type> OnRemoteMediaSourceChanged; public event EventHandler<Type> OnRemoteMediaSourceChanged;
public event EventHandler OnTraktChanged; public event EventHandler OnTraktChanged;
public event EventHandler OnEmbyCollectionsChanged; public event EventHandler OnEmbyCollectionsChanged;
public event EventHandler<int> OnPlayoutChanged;
public bool LockLibrary(int libraryId) public bool LockLibrary(int libraryId)
{ {
@ -155,4 +158,28 @@ public class EntityLocker : IEntityLocker
} }
public bool AreEmbyCollectionsLocked() => _embyCollections; public bool AreEmbyCollectionsLocked() => _embyCollections;
public bool LockPlayout(int playoutId)
{
if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0))
{
OnPlayoutChanged?.Invoke(this, playoutId);
return true;
}
return false;
}
public bool UnlockPlayout(int playoutId)
{
if (_lockedPlayouts.TryRemove(playoutId, out byte _))
{
OnPlayoutChanged?.Invoke(this, playoutId);
return true;
}
return false;
}
public bool IsPlayoutLocked(int playoutId) => _lockedPlayouts.ContainsKey(playoutId);
} }

51
ErsatzTV/Pages/Playouts.razor

@ -3,9 +3,10 @@
@using ErsatzTV.Application.Configuration @using ErsatzTV.Application.Configuration
@using ErsatzTV.Core.Scheduling @using ErsatzTV.Core.Scheduling
@implements IDisposable @implements IDisposable
@inject IDialogService _dialog @inject IDialogService Dialog
@inject IMediator _mediator @inject IMediator Mediator
@inject ChannelWriter<IBackgroundServiceRequest> WorkerChannel; @inject ChannelWriter<IBackgroundServiceRequest> WorkerChannel;
@inject IEntityLocker EntityLocker;
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="playouts/add"> <MudButton Variant="Variant.Filled" Color="Color.Primary" Link="playouts/add">
@ -46,23 +47,33 @@
@* <MudTd DataLabel="Playout Type">@context.ProgramSchedulePlayoutType</MudTd> *@ @* <MudTd DataLabel="Playout Type">@context.ProgramSchedulePlayoutType</MudTd> *@
<MudTd> <MudTd>
<div style="align-items: center; display: flex;"> <div style="align-items: center; display: flex;">
@if (EntityLocker.IsPlayoutLocked(context.PlayoutId))
{
<div style="align-items: center; display: flex; height: 48px; justify-content: center; width: 48px;">
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
</div>
}
<MudTooltip Text="Edit Alternate Schedules"> <MudTooltip Text="Edit Alternate Schedules">
<MudIconButton Icon="@Icons.Material.Filled.EditCalendar" <MudIconButton Icon="@Icons.Material.Filled.EditCalendar"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
Link="@($"playouts/{context.PlayoutId}/alternate-schedules")"> Link="@($"playouts/{context.PlayoutId}/alternate-schedules")">
</MudIconButton> </MudIconButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="Reset Playout"> <MudTooltip Text="Reset Playout">
<MudIconButton Icon="@Icons.Material.Filled.Refresh" <MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ResetPlayout(context))"> OnClick="@(_ => ResetPlayout(context))">
</MudIconButton> </MudIconButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="Schedule Reset"> <MudTooltip Text="Schedule Reset">
<MudIconButton Icon="@Icons.Material.Filled.Update" <MudIconButton Icon="@Icons.Material.Filled.Update"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => ScheduleReset(context))"> OnClick="@(_ => ScheduleReset(context))">
</MudIconButton> </MudIconButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="Delete Playout"> <MudTooltip Text="Delete Playout">
<MudIconButton Icon="@Icons.Material.Filled.Delete" <MudIconButton Icon="@Icons.Material.Filled.Delete"
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
OnClick="@(_ => DeletePlayout(context))"> OnClick="@(_ => DeletePlayout(context))">
</MudIconButton> </MudIconButton>
</MudTooltip> </MudTooltip>
@ -131,17 +142,21 @@
public void Dispose() public void Dispose()
{ {
EntityLocker.OnPlayoutChanged -= ReloadDetailsIfNeeded;
_cts.Cancel(); _cts.Cancel();
_cts.Dispose(); _cts.Dispose();
} }
protected override void OnInitialized() => EntityLocker.OnPlayoutChanged += ReloadDetailsIfNeeded;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
_rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsPageSize), _cts.Token) _rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsPageSize), _cts.Token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); .Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
_detailRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize), _cts.Token) _detailRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize), _cts.Token)
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); .Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
_showFiller = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailShowFiller), _cts.Token) _showFiller = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailShowFiller), _cts.Token)
.Map(maybeShow => maybeShow.Match(ce => bool.TryParse(ce.Value, out bool show) && show, () => false)); .Map(maybeShow => maybeShow.Match(ce => bool.TryParse(ce.Value, out bool show) && show, () => false));
} }
@ -159,11 +174,11 @@
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}" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await _dialog.ShowAsync<DeleteDialog>("Delete Playout", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Playout", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled) if (!result.Canceled)
{ {
await _mediator.Send(new DeletePlayout(playout.PlayoutId), _cts.Token); await Mediator.Send(new DeletePlayout(playout.PlayoutId), _cts.Token);
if (_table != null) if (_table != null)
{ {
await _table.ReloadServerData(); await _table.ReloadServerData();
@ -199,7 +214,7 @@
}; };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await _dialog.ShowAsync<SchedulePlayoutReset>("Schedule Playout Reset", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<SchedulePlayoutReset>("Schedule Playout Reset", parameters, options);
await dialog.Result; await dialog.Result;
if (_table != null) if (_table != null)
@ -210,9 +225,9 @@
private async Task<TableData<PlayoutNameViewModel>> ServerReload(TableState state) private async Task<TableData<PlayoutNameViewModel>> ServerReload(TableState state)
{ {
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsPageSize, state.PageSize.ToString()), _cts.Token); await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsPageSize, state.PageSize.ToString()), _cts.Token);
List<PlayoutNameViewModel> playouts = await _mediator.Send(new GetAllPlayouts(), _cts.Token); List<PlayoutNameViewModel> playouts = await Mediator.Send(new GetAllPlayouts(), _cts.Token);
IOrderedEnumerable<PlayoutNameViewModel> sorted = playouts.OrderBy(p => decimal.Parse(p.ChannelNumber)); IOrderedEnumerable<PlayoutNameViewModel> sorted = playouts.OrderBy(p => decimal.Parse(p.ChannelNumber));
// TODO: properly page this data // TODO: properly page this data
@ -223,15 +238,25 @@
}; };
} }
private async void ReloadDetailsIfNeeded(object sender, int playoutId)
{
if (playoutId == _selectedPlayoutId && _detailTable is not null)
{
await InvokeAsync(() => _detailTable.ReloadServerData());
}
await InvokeAsync(StateHasChanged);
}
private async Task<TableData<PlayoutItemViewModel>> DetailServerReload(TableState state) private async Task<TableData<PlayoutItemViewModel>> DetailServerReload(TableState state)
{ {
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize, state.PageSize.ToString()), _cts.Token); await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize, state.PageSize.ToString()), _cts.Token);
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailShowFiller, _showFiller.ToString()), _cts.Token); await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailShowFiller, _showFiller.ToString()), _cts.Token);
if (_selectedPlayoutId.HasValue) if (_selectedPlayoutId.HasValue)
{ {
PagedPlayoutItemsViewModel data = PagedPlayoutItemsViewModel data =
await _mediator.Send(new GetFuturePlayoutItemsById(_selectedPlayoutId.Value, _showFiller, state.Page, state.PageSize), _cts.Token); await Mediator.Send(new GetFuturePlayoutItemsById(_selectedPlayoutId.Value, _showFiller, state.Page, state.PageSize), _cts.Token);
return new TableData<PlayoutItemViewModel> return new TableData<PlayoutItemViewModel>
{ {
TotalItems = data.TotalCount, TotalItems = data.TotalCount,

Loading…
Cancel
Save