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. 108
      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/). @@ -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

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

@ -27,7 +27,7 @@ public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory> @@ -27,7 +27,7 @@ public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
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");

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

@ -82,16 +82,14 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -82,16 +82,14 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_localFileSystem,
_sessionWorkerLogger,
targetFramerate);
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => 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<StartFFmpegSession, Eit @@ -169,12 +167,12 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
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)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelSessionAlreadyActive());
if (result.IsFail && _ffmpegSegmenterService.SessionWorkers.TryGetValue(
if (result.IsFail && _ffmpegSegmenterService.TryGetWorker(
request.ChannelNumber,
out IHlsSessionWorker worker))
{

56
ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs

@ -4,22 +4,60 @@ using Microsoft.Extensions.Logging; @@ -4,22 +4,60 @@ using Microsoft.Extensions.Logging;
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)
{
_logger = logger;
bool result = _sessionWorkers.TryAdd(channelNumber, worker);
if (result)
{
OnWorkersChanged?.Invoke(this, EventArgs.Empty);
}
SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>();
return result;
}
public ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
public void AddOrUpdateWorker(string channelNumber, IHlsSessionWorker worker)
{
_sessionWorkers.AddOrUpdate(channelNumber, _ => worker, (_, _) => worker);
OnWorkersChanged?.Invoke(this, EventArgs.Empty);
}
public void RemoveWorker(string channelNumber, out IHlsSessionWorker inactiveWorker)
{
_sessionWorkers.TryRemove(channelNumber, out inactiveWorker);
OnWorkersChanged?.Invoke(this, EventArgs.Empty);
}
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)
{
if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
if (_sessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{
worker?.Touch();
}
@ -27,11 +65,11 @@ public class FFmpegSegmenterService : IFFmpegSegmenterService @@ -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);

14
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs

@ -1,11 +1,15 @@ @@ -1,11 +1,15 @@
using System.Collections.Concurrent;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
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 PlayoutUpdated(string channelNumber);
}

5
ErsatzTV/Controllers/Api/SessionController.cs

@ -10,15 +10,14 @@ public class SessionController(IFFmpegSegmenterService ffmpegSegmenterService) @@ -10,15 +10,14 @@ public class SessionController(IFFmpegSegmenterService ffmpegSegmenterService)
[HttpGet("api/sessions")]
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}")]
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();
}

2
ErsatzTV/Controllers/IptvController.cs

@ -142,7 +142,7 @@ public class IptvController : ControllerBase @@ -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);

108
ErsatzTV/Pages/Channels.razor

@ -3,9 +3,12 @@ @@ -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
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true"
@ -22,7 +25,7 @@ @@ -22,7 +25,7 @@
<col style="width: 15%"/>
<col style="width: 15%"/>
<col style="width: 15%"/>
<col style="width: 120px;"/>
<col style="width: 240px;"/>
</ColGroup>
<HeaderContent>
<MudTh>
@ -56,6 +59,33 @@ @@ -56,6 +59,33 @@
</MudTd>
<MudTd>
<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">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"channels/{context.Id}")">
@ -86,29 +116,86 @@ @@ -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<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)
{
var parameters = new DialogParameters { { "EntityType", "channel" }, { "EntityName", channel.Name } };
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;
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 @@ @@ -118,9 +205,9 @@
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));
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
@ -146,7 +233,8 @@ @@ -146,7 +233,8 @@
};
}
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",

37
ErsatzTV/Pages/_Host.cshtml

@ -20,6 +20,8 @@ @@ -20,6 +20,8 @@
<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/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")
<script>
function sortableCollection(collectionId) {
@ -48,6 +50,41 @@ @@ -48,6 +50,41 @@
$("h2").addClass("mud-typography mud-typography-h4");
$("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>
</head>
<body>

62
ErsatzTV/Shared/ChannelPreviewDialog.razor

@ -0,0 +1,62 @@ @@ -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