From 7fffc8cf6324759a787d6dad9ae108eb57716161 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 29 Jan 2024 20:52:52 -0600 Subject: [PATCH] channel preview player (#1579) * add channel preview * add button to stop transcoding session --- CHANGELOG.md | 5 + .../Commands/ReleaseMemoryHandler.cs | 2 +- .../Commands/StartFFmpegSessionHandler.cs | 10 +- .../FFmpeg/FFmpegSegmenterService.cs | 56 +++++++-- .../FFmpeg/IFFmpegSegmenterService.cs | 14 ++- ErsatzTV/Controllers/Api/SessionController.cs | 5 +- ErsatzTV/Controllers/IptvController.cs | 2 +- ErsatzTV/Pages/Channels.razor | 110 ++++++++++++++++-- ErsatzTV/Pages/_Host.cshtml | 37 ++++++ ErsatzTV/Shared/ChannelPreviewDialog.razor | 62 ++++++++++ 10 files changed, 267 insertions(+), 36 deletions(-) create mode 100644 ErsatzTV/Shared/ChannelPreviewDialog.razor diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fbc53619..14ade3484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Show brief info about all active sessions - DELETE `/api/session/{channel-number}` - Stop the session for the given channel number +- Add channel preview (web-based video player) + - Channels MUST use `H264` video format and `AAC` audio format + - Channels MUST use `MPEG-TS` or `HLS Segmenter` streaming modes + - Since `MPEG-TS` uses `HLS Segmenter` under the hood, the preview player will use `HLS Segmenter`, so it's not 100% equivalent, but it should be representative +- Add button to stop transcoding session for each channel that has an active session ### Fixed - Fix error loading path replacements when using MySql diff --git a/ErsatzTV.Application/Maintenance/Commands/ReleaseMemoryHandler.cs b/ErsatzTV.Application/Maintenance/Commands/ReleaseMemoryHandler.cs index cac602a24..279b056e6 100644 --- a/ErsatzTV.Application/Maintenance/Commands/ReleaseMemoryHandler.cs +++ b/ErsatzTV.Application/Maintenance/Commands/ReleaseMemoryHandler.cs @@ -27,7 +27,7 @@ public class ReleaseMemoryHandler : IRequestHandler return Task.CompletedTask; } - bool hasActiveWorkers = !_ffmpegSegmenterService.SessionWorkers.IsEmpty || FFmpegProcess.ProcessCount > 0; + bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count >= 0 || FFmpegProcess.ProcessCount > 0; if (request.ForceAggressive || !hasActiveWorkers) { _logger.LogDebug("Starting aggressive garbage collection"); diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs index a86fe8174..bd97dfe7e 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs @@ -82,16 +82,14 @@ public class StartFFmpegSessionHandler : IRequestHandler worker, (_, _) => worker); + _ffmpegSegmenterService.AddOrUpdateWorker(request.ChannelNumber, worker); // fire and forget worker _ = worker.Run(request.ChannelNumber, idleTimeout, _hostApplicationLifetime.ApplicationStopping) .ContinueWith( _ => { - _ffmpegSegmenterService.SessionWorkers.TryRemove( - request.ChannelNumber, - out IHlsSessionWorker inactiveWorker); + _ffmpegSegmenterService.RemoveWorker(request.ChannelNumber, out IHlsSessionWorker inactiveWorker); inactiveWorker?.Dispose(); @@ -169,12 +167,12 @@ public class StartFFmpegSessionHandler : IRequestHandler> SessionMustBeInactive(StartFFmpegSession request) { - var result = Optional(_ffmpegSegmenterService.SessionWorkers.TryAdd(request.ChannelNumber, null)) + var result = Optional(_ffmpegSegmenterService.TryAddWorker(request.ChannelNumber, null)) .Where(success => success) .Map(_ => Unit.Default) .ToValidation(new ChannelSessionAlreadyActive()); - if (result.IsFail && _ffmpegSegmenterService.SessionWorkers.TryGetValue( + if (result.IsFail && _ffmpegSegmenterService.TryGetWorker( request.ChannelNumber, out IHlsSessionWorker worker)) { diff --git a/ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs b/ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs index fcab77f1b..69913744a 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs @@ -4,22 +4,60 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.FFmpeg; -public class FFmpegSegmenterService : IFFmpegSegmenterService +public class FFmpegSegmenterService(ILogger logger) : IFFmpegSegmenterService { - private readonly ILogger _logger; + private readonly ConcurrentDictionary _sessionWorkers = new(); - public FFmpegSegmenterService(ILogger logger) + public event EventHandler OnWorkersChanged; + + public ICollection Workers => _sessionWorkers.Values; + + public bool TryGetWorker(string channelNumber, out IHlsSessionWorker worker) => + _sessionWorkers.TryGetValue(channelNumber, out worker); + + public bool TryAddWorker(string channelNumber, IHlsSessionWorker worker) + { + bool result = _sessionWorkers.TryAdd(channelNumber, worker); + + if (result) + { + OnWorkersChanged?.Invoke(this, EventArgs.Empty); + } + + return result; + } + + public void AddOrUpdateWorker(string channelNumber, IHlsSessionWorker worker) { - _logger = logger; + _sessionWorkers.AddOrUpdate(channelNumber, _ => worker, (_, _) => worker); + OnWorkersChanged?.Invoke(this, EventArgs.Empty); + } - SessionWorkers = new ConcurrentDictionary(); + public void RemoveWorker(string channelNumber, out IHlsSessionWorker inactiveWorker) + { + _sessionWorkers.TryRemove(channelNumber, out inactiveWorker); + OnWorkersChanged?.Invoke(this, EventArgs.Empty); } - public ConcurrentDictionary SessionWorkers { get; } + public bool IsActive(string channelNumber) => _sessionWorkers.ContainsKey(channelNumber); + + public async Task StopChannel(string channelNumber, CancellationToken cancellationToken) + { + if (_sessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker)) + { + if (worker != null) + { + await worker.Cancel(cancellationToken); + return true; + } + } + + return false; + } public void TouchChannel(string channelNumber) { - if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker)) + if (_sessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker)) { worker?.Touch(); } @@ -27,11 +65,11 @@ public class FFmpegSegmenterService : IFFmpegSegmenterService public void PlayoutUpdated(string channelNumber) { - if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker)) + if (_sessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker)) { if (worker != null) { - _logger.LogInformation( + logger.LogInformation( "Playout has been updated for channel {ChannelNumber}, HLS segmenter will skip ahead to catch up", channelNumber); diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs index 06544f224..ec8e34e0a 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs @@ -1,11 +1,15 @@ -using System.Collections.Concurrent; - -namespace ErsatzTV.Core.Interfaces.FFmpeg; +namespace ErsatzTV.Core.Interfaces.FFmpeg; public interface IFFmpegSegmenterService { - ConcurrentDictionary SessionWorkers { get; } - + event EventHandler OnWorkersChanged; + ICollection Workers { get; } + bool TryGetWorker(string channelNumber, out IHlsSessionWorker worker); + bool TryAddWorker(string channelNumber, IHlsSessionWorker worker); + void AddOrUpdateWorker(string channelNumber, IHlsSessionWorker worker); + void RemoveWorker(string channelNumber, out IHlsSessionWorker inactiveWorker); + bool IsActive(string channelNumber); + Task StopChannel(string channelNumber, CancellationToken cancellationToken); void TouchChannel(string channelNumber); void PlayoutUpdated(string channelNumber); } diff --git a/ErsatzTV/Controllers/Api/SessionController.cs b/ErsatzTV/Controllers/Api/SessionController.cs index 0c91b36ee..e75b9b625 100644 --- a/ErsatzTV/Controllers/Api/SessionController.cs +++ b/ErsatzTV/Controllers/Api/SessionController.cs @@ -10,15 +10,14 @@ public class SessionController(IFFmpegSegmenterService ffmpegSegmenterService) [HttpGet("api/sessions")] public List GetSessions() { - return ffmpegSegmenterService.SessionWorkers.Values.Map(w => w.GetModel()).ToList(); + return ffmpegSegmenterService.Workers.Map(w => w.GetModel()).ToList(); } [HttpDelete("api/session/{channelNumber}")] public async Task StopSession(string channelNumber, CancellationToken cancellationToken) { - if (ffmpegSegmenterService.SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker)) + if (await ffmpegSegmenterService.StopChannel(channelNumber, cancellationToken)) { - await worker.Cancel(cancellationToken); return new NoContentResult(); } diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index 6118c9203..4c45b761a 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -142,7 +142,7 @@ public class IptvController : ControllerBase { // _logger.LogDebug("Checking for session worker for channel {Channel}", channelNumber); - if (_ffmpegSegmenterService.SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker)) + if (_ffmpegSegmenterService.TryGetWorker(channelNumber, out IHlsSessionWorker worker)) { // _logger.LogDebug("Trimming playlist for channel {Channel}", channelNumber); diff --git a/ErsatzTV/Pages/Channels.razor b/ErsatzTV/Pages/Channels.razor index 90e6389ce..3b5e2c8db 100644 --- a/ErsatzTV/Pages/Channels.razor +++ b/ErsatzTV/Pages/Channels.razor @@ -3,9 +3,12 @@ @using ErsatzTV.Application.Configuration @using ErsatzTV.Application.FFmpegProfiles @using System.Globalization +@using ErsatzTV.Core.Interfaces.FFmpeg @implements IDisposable -@inject IDialogService _dialog -@inject IMediator _mediator +@inject IDialogService Dialog +@inject IMediator Mediator +@inject NavigationManager NavigationManager +@inject IFFmpegSegmenterService SegmenterService - + @@ -56,6 +59,33 @@
+ @if (CanPreviewChannel(context)) + { + + + + + } + else + { + + + + + } + @if (SegmenterService.IsActive(context.Number)) + { + + + + + } + else + { +
+ } @@ -86,29 +116,86 @@ private int _rowsPerPage = 10; + protected override void OnInitialized() + { + SegmenterService.OnWorkersChanged += WorkersChanged; + } + + private void WorkersChanged(object sender, EventArgs e) => + InvokeAsync(StateHasChanged); + public void Dispose() { + SegmenterService.OnWorkersChanged -= WorkersChanged; + _cts.Cancel(); _cts.Dispose(); } protected override async Task OnParametersSetAsync() { - _ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles(), _cts.Token); - _rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.ChannelsPageSize), _cts.Token) + _ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), _cts.Token); + _rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.ChannelsPageSize), _cts.Token) .Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); } + private async Task StopChannel(ChannelViewModel channel) + { + await SegmenterService.StopChannel(channel.Number, _cts.Token); + } + + private bool CanPreviewChannel(ChannelViewModel channel) + { + if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect or StreamingMode.TransportStream) + { + return false; + } + + Option maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == channel.FFmpegProfileId)); + foreach (FFmpegProfileViewModel profile in maybeProfile) + { + return profile.VideoFormat is FFmpegProfileVideoFormat.H264 && profile.AudioFormat is FFmpegProfileAudioFormat.Aac; + } + + return false; + } + + private async Task PreviewChannel(ChannelViewModel channel) + { + if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect or StreamingMode.TransportStream) + { + return; + } + + Option maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == channel.FFmpegProfileId)); + foreach (FFmpegProfileViewModel profile in maybeProfile) + { + if (profile.VideoFormat == FFmpegProfileVideoFormat.Hevc) + { + return; + } + + string currentUri = NavigationManager.Uri; + string streamUri = currentUri.Replace("/channels", $"/iptv/channel/{channel.Number}.m3u8?mode=segmenter"); + + Serilog.Log.Logger.Information("Stream uri: {StreamUri}", streamUri); + + var parameters = new DialogParameters { { "StreamUri", streamUri } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge }; + await Dialog.ShowAsync("Channel Preview", parameters, options); + } + } + private async Task DeleteChannelAsync(ChannelViewModel channel) { var parameters = new DialogParameters { { "EntityType", "channel" }, { "EntityName", channel.Name } }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; - IDialogReference dialog = await _dialog.ShowAsync("Delete Channel", parameters, options); + IDialogReference dialog = await Dialog.ShowAsync("Delete Channel", parameters, options); DialogResult result = await dialog.Result; if (!result.Canceled) { - await _mediator.Send(new DeleteChannel(channel.Id), _cts.Token); + await Mediator.Send(new DeleteChannel(channel.Id), _cts.Token); if (_table != null) { await _table.ReloadServerData(); @@ -118,9 +205,9 @@ private async Task> ServerReload(TableState state) { - await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.ChannelsPageSize, state.PageSize.ToString()), _cts.Token); + await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.ChannelsPageSize, state.PageSize.ToString()), _cts.Token); - List channels = await _mediator.Send(new GetAllChannels(), _cts.Token); + List channels = await Mediator.Send(new GetAllChannels(), _cts.Token); IOrderedEnumerable sorted = channels.OrderBy(c => decimal.Parse(c.Number)); CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures); @@ -146,11 +233,12 @@ }; } - private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch { + private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch + { StreamingMode.HttpLiveStreamingDirect => "HLS Direct", StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter", StreamingMode.TransportStreamHybrid => "MPEG-TS", _ => "MPEG-TS (Legacy)" - }; + }; } \ No newline at end of file diff --git a/ErsatzTV/Pages/_Host.cshtml b/ErsatzTV/Pages/_Host.cshtml index 3d1c8c1f9..2273dd726 100644 --- a/ErsatzTV/Pages/_Host.cshtml +++ b/ErsatzTV/Pages/_Host.cshtml @@ -20,6 +20,8 @@ + + @await Html.PartialAsync("../Shared/_Favicons") diff --git a/ErsatzTV/Shared/ChannelPreviewDialog.razor b/ErsatzTV/Shared/ChannelPreviewDialog.razor new file mode 100644 index 000000000..092ee60b8 --- /dev/null +++ b/ErsatzTV/Shared/ChannelPreviewDialog.razor @@ -0,0 +1,62 @@ +@inject IJSRuntime JsRuntime + +
+ + + + + + + + + + + + + Close + + +
+ +@code { + + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } + + [Parameter] + public string StreamUri { get; set; } + + protected override Task OnParametersSetAsync() + { + return Task.CompletedTask; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + try + { + await JsRuntime.InvokeVoidAsync("previewChannel", StreamUri); + } + catch (Exception) + { + // ignored + } + + await base.OnAfterRenderAsync(true); + } + + private void Close() + { + try + { + JsRuntime.InvokeVoidAsync("stopPreview"); + } + catch (Exception) + { + // ignored + } + + MudDialog.Close(DialogResult.Ok(true)); + } + +} \ No newline at end of file