Browse Source

channel preview player (#1579)

* add channel preview

* add button to stop transcoding session
pull/1580/head
Jason Dove 2 years ago committed by GitHub
parent
commit
7fffc8cf63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Maintenance/Commands/ReleaseMemoryHandler.cs
  3. 10
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  4. 56
      ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs
  5. 14
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs
  6. 5
      ErsatzTV/Controllers/Api/SessionController.cs
  7. 2
      ErsatzTV/Controllers/IptvController.cs
  8. 110
      ErsatzTV/Pages/Channels.razor
  9. 37
      ErsatzTV/Pages/_Host.cshtml
  10. 62
      ErsatzTV/Shared/ChannelPreviewDialog.razor

5
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 - Show brief info about all active sessions
- DELETE `/api/session/{channel-number}` - DELETE `/api/session/{channel-number}`
- Stop the session for the given 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 ### Fixed
- Fix error loading path replacements when using MySql - Fix error loading path replacements when using MySql

2
ErsatzTV.Application/Maintenance/Commands/ReleaseMemoryHandler.cs

@ -27,7 +27,7 @@ public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
return Task.CompletedTask; return Task.CompletedTask;
} }
bool hasActiveWorkers = !_ffmpegSegmenterService.SessionWorkers.IsEmpty || FFmpegProcess.ProcessCount > 0; bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count >= 0 || FFmpegProcess.ProcessCount > 0;
if (request.ForceAggressive || !hasActiveWorkers) if (request.ForceAggressive || !hasActiveWorkers)
{ {
_logger.LogDebug("Starting aggressive garbage collection"); _logger.LogDebug("Starting aggressive garbage collection");

10
ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs

@ -82,16 +82,14 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_localFileSystem, _localFileSystem,
_sessionWorkerLogger, _sessionWorkerLogger,
targetFramerate); targetFramerate);
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker); _ffmpegSegmenterService.AddOrUpdateWorker(request.ChannelNumber, worker);
// fire and forget worker // fire and forget worker
_ = worker.Run(request.ChannelNumber, idleTimeout, _hostApplicationLifetime.ApplicationStopping) _ = worker.Run(request.ChannelNumber, idleTimeout, _hostApplicationLifetime.ApplicationStopping)
.ContinueWith( .ContinueWith(
_ => _ =>
{ {
_ffmpegSegmenterService.SessionWorkers.TryRemove( _ffmpegSegmenterService.RemoveWorker(request.ChannelNumber, out IHlsSessionWorker inactiveWorker);
request.ChannelNumber,
out IHlsSessionWorker inactiveWorker);
inactiveWorker?.Dispose(); inactiveWorker?.Dispose();
@ -169,12 +167,12 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request) private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
{ {
var result = Optional(_ffmpegSegmenterService.SessionWorkers.TryAdd(request.ChannelNumber, null)) var result = Optional(_ffmpegSegmenterService.TryAddWorker(request.ChannelNumber, null))
.Where(success => success) .Where(success => success)
.Map(_ => Unit.Default) .Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelSessionAlreadyActive()); .ToValidation<BaseError>(new ChannelSessionAlreadyActive());
if (result.IsFail && _ffmpegSegmenterService.SessionWorkers.TryGetValue( if (result.IsFail && _ffmpegSegmenterService.TryGetWorker(
request.ChannelNumber, request.ChannelNumber,
out IHlsSessionWorker worker)) out IHlsSessionWorker worker))
{ {

56
ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs

@ -4,22 +4,60 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.FFmpeg;
public class FFmpegSegmenterService : IFFmpegSegmenterService public class FFmpegSegmenterService(ILogger<FFmpegSegmenterService> logger) : IFFmpegSegmenterService
{ {
private readonly ILogger<FFmpegSegmenterService> _logger; private readonly ConcurrentDictionary<string, IHlsSessionWorker> _sessionWorkers = new();
public FFmpegSegmenterService(ILogger<FFmpegSegmenterService> logger) public event EventHandler OnWorkersChanged;
public ICollection<IHlsSessionWorker> 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<string, IHlsSessionWorker>(); public void RemoveWorker(string channelNumber, out IHlsSessionWorker inactiveWorker)
{
_sessionWorkers.TryRemove(channelNumber, out inactiveWorker);
OnWorkersChanged?.Invoke(this, EventArgs.Empty);
} }
public ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; } public bool IsActive(string channelNumber) => _sessionWorkers.ContainsKey(channelNumber);
public async Task<bool> 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) public void TouchChannel(string channelNumber)
{ {
if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker)) if (_sessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{ {
worker?.Touch(); worker?.Touch();
} }
@ -27,11 +65,11 @@ public class FFmpegSegmenterService : IFFmpegSegmenterService
public void PlayoutUpdated(string channelNumber) public void PlayoutUpdated(string channelNumber)
{ {
if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker)) if (_sessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{ {
if (worker != null) if (worker != null)
{ {
_logger.LogInformation( logger.LogInformation(
"Playout has been updated for channel {ChannelNumber}, HLS segmenter will skip ahead to catch up", "Playout has been updated for channel {ChannelNumber}, HLS segmenter will skip ahead to catch up",
channelNumber); channelNumber);

14
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 public interface IFFmpegSegmenterService
{ {
ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; } event EventHandler OnWorkersChanged;
ICollection<IHlsSessionWorker> 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<bool> StopChannel(string channelNumber, CancellationToken cancellationToken);
void TouchChannel(string channelNumber); void TouchChannel(string channelNumber);
void PlayoutUpdated(string channelNumber); void PlayoutUpdated(string channelNumber);
} }

5
ErsatzTV/Controllers/Api/SessionController.cs

@ -10,15 +10,14 @@ public class SessionController(IFFmpegSegmenterService ffmpegSegmenterService)
[HttpGet("api/sessions")] [HttpGet("api/sessions")]
public List<HlsSessionModel> GetSessions() public List<HlsSessionModel> GetSessions()
{ {
return ffmpegSegmenterService.SessionWorkers.Values.Map(w => w.GetModel()).ToList(); return ffmpegSegmenterService.Workers.Map(w => w.GetModel()).ToList();
} }
[HttpDelete("api/session/{channelNumber}")] [HttpDelete("api/session/{channelNumber}")]
public async Task<IActionResult> StopSession(string channelNumber, CancellationToken cancellationToken) public async Task<IActionResult> 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(); return new NoContentResult();
} }

2
ErsatzTV/Controllers/IptvController.cs

@ -142,7 +142,7 @@ public class IptvController : ControllerBase
{ {
// _logger.LogDebug("Checking for session worker for channel {Channel}", channelNumber); // _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); // _logger.LogDebug("Trimming playlist for channel {Channel}", channelNumber);

110
ErsatzTV/Pages/Channels.razor

@ -3,9 +3,12 @@
@using ErsatzTV.Application.Configuration @using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.FFmpegProfiles
@using System.Globalization @using System.Globalization
@using ErsatzTV.Core.Interfaces.FFmpeg
@implements IDisposable @implements IDisposable
@inject IDialogService _dialog @inject IDialogService Dialog
@inject IMediator _mediator @inject IMediator Mediator
@inject NavigationManager NavigationManager
@inject IFFmpegSegmenterService SegmenterService
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" <MudTable Hover="true"
@ -22,7 +25,7 @@
<col style="width: 15%"/> <col style="width: 15%"/>
<col style="width: 15%"/> <col style="width: 15%"/>
<col style="width: 15%"/> <col style="width: 15%"/>
<col style="width: 120px;"/> <col style="width: 240px;"/>
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
<MudTh> <MudTh>
@ -56,6 +59,33 @@
</MudTd> </MudTd>
<MudTd> <MudTd>
<div style="align-items: center; display: flex;"> <div style="align-items: center; display: flex;">
@if (CanPreviewChannel(context))
{
<MudTooltip Text="Preview Channel">
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle"
OnClick="@(_ => PreviewChannel(context))">
</MudIconButton>
</MudTooltip>
}
else
{
<MudTooltip Text="Channel preview requires MPEG-TS/HLS Segmenter and H264/AAC">
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle" Disabled="true">
</MudIconButton>
</MudTooltip>
}
@if (SegmenterService.IsActive(context.Number))
{
<MudTooltip Text="Stop Transcode Session">
<MudIconButton Icon="@Icons.Material.Filled.Stop"
OnClick="@(_ => StopChannel(context))">
</MudIconButton>
</MudTooltip>
}
else
{
<div style="width: 48px"></div>
}
<MudTooltip Text="Edit Channel"> <MudTooltip Text="Edit Channel">
<MudIconButton Icon="@Icons.Material.Filled.Edit" <MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"channels/{context.Id}")"> Link="@($"channels/{context.Id}")">
@ -86,29 +116,86 @@
private int _rowsPerPage = 10; private int _rowsPerPage = 10;
protected override void OnInitialized()
{
SegmenterService.OnWorkersChanged += WorkersChanged;
}
private void WorkersChanged(object sender, EventArgs e) =>
InvokeAsync(StateHasChanged);
public void Dispose() public void Dispose()
{ {
SegmenterService.OnWorkersChanged -= WorkersChanged;
_cts.Cancel(); _cts.Cancel();
_cts.Dispose(); _cts.Dispose();
} }
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
_ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles(), _cts.Token); _ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), _cts.Token);
_rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.ChannelsPageSize), _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)); .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<FFmpegProfileViewModel> 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<FFmpegProfileViewModel> 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<ChannelPreviewDialog>("Channel Preview", parameters, options);
}
}
private async Task DeleteChannelAsync(ChannelViewModel channel) private async Task DeleteChannelAsync(ChannelViewModel channel)
{ {
var parameters = new DialogParameters { { "EntityType", "channel" }, { "EntityName", channel.Name } }; var parameters = new DialogParameters { { "EntityType", "channel" }, { "EntityName", channel.Name } };
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 Channel", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Channel", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled) if (!result.Canceled)
{ {
await _mediator.Send(new DeleteChannel(channel.Id), _cts.Token); await Mediator.Send(new DeleteChannel(channel.Id), _cts.Token);
if (_table != null) if (_table != null)
{ {
await _table.ReloadServerData(); await _table.ReloadServerData();
@ -118,9 +205,9 @@
private async Task<TableData<ChannelViewModel>> ServerReload(TableState state) private async Task<TableData<ChannelViewModel>> 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<ChannelViewModel> channels = await _mediator.Send(new GetAllChannels(), _cts.Token); List<ChannelViewModel> channels = await Mediator.Send(new GetAllChannels(), _cts.Token);
IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number)); IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number));
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures); 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.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter", StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
StreamingMode.TransportStreamHybrid => "MPEG-TS", StreamingMode.TransportStreamHybrid => "MPEG-TS",
_ => "MPEG-TS (Legacy)" _ => "MPEG-TS (Legacy)"
}; };
} }

37
ErsatzTV/Pages/_Host.cshtml

@ -20,6 +20,8 @@
<link href="//code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css" rel="stylesheet"> <link href="//code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css" rel="stylesheet">
<script src="https://code.jquery.com/jquery-1.12.4.js"></script> <script src="https://code.jquery.com/jquery-1.12.4.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script> <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome@1/+esm"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
@await Html.PartialAsync("../Shared/_Favicons") @await Html.PartialAsync("../Shared/_Favicons")
<script> <script>
function sortableCollection(collectionId) { function sortableCollection(collectionId) {
@ -48,6 +50,41 @@
$("h2").addClass("mud-typography mud-typography-h4"); $("h2").addClass("mud-typography mud-typography-h4");
$("h3").addClass("mud-typography mud-typography-h5"); $("h3").addClass("mud-typography mud-typography-h5");
} }
function previewChannel(uri) {
var video = document.getElementById('video');
if (Hls.isSupported()) {
var hls = new Hls({
debug: true,
});
$('#video').data('hls', hls);
hls.loadSource(uri);
if (uri.endsWith('ts')) {
hls.on(Hls.Events.MEDIA_ATTACHED, function () {
video.play();
});
} else {
hls.on(Hls.Events.MANIFEST_PARSED, function () {
video.play();
});
}
hls.attachMedia(video);
}
// hls.js is not supported on platforms that do not have Media Source Extensions (MSE) enabled.
// When the browser has built-in HLS support (check using `canPlayType`), we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video element through the `src` property.
// This is using the built-in support of the plain video element, without using hls.js.
else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = uri;
video.addEventListener('canplay', function () {
video.play();
});
}
}
function stopPreview() {
var hls = $('#video').data('hls');
hls && hls.destroy();
}
</script> </script>
</head> </head>
<body> <body>

62
ErsatzTV/Shared/ChannelPreviewDialog.razor

@ -0,0 +1,62 @@
@inject IJSRuntime JsRuntime
<div>
<MudDialog>
<DialogContent>
<media-controller style="width: 800px; height: calc(800px * 9/16)">
<video id="video" slot="media"></video>
<media-control-bar>
<media-mute-button></media-mute-button>
<media-volume-range></media-volume-range>
<media-fullscreen-button></media-fullscreen-button>
</media-control-bar>
</media-controller>
</DialogContent>
<DialogActions>
<MudButton Color="Color.Primary" OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
</div>
@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));
}
}
Loading…
Cancel
Save